Bug 1267810 - Use the new module to implement the omnibox api. r=aswan,robwu
☠☠ backed out by 8303f902a05e ☠ ☠
authorMatthew Wein <mwein@mozilla.com>
Thu, 03 Nov 2016 16:28:30 +0000
changeset 347658 be4455a7669f44f978b182fe05118870fe5d8cb8
parent 347657 5b9b56fecb95b665814d85b4b20d183a9b9bdea6
child 347659 be3718436994fc1a8e690110649626aabcf830fc
push id10298
push userraliiev@mozilla.com
push dateMon, 14 Nov 2016 12:33:03 +0000
treeherdermozilla-aurora@7e29173b1641 [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/xpcshell/test_ext_manifest_omnibox.js
browser/components/extensions/test/xpcshell/xpcshell.ini
toolkit/components/places/ExtensionSearchHandler.jsm
toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js
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 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 browserAction chrome://browser/content/ext-c-browserAction.js
 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 pageAction chrome://browser/content/ext-c-pageAction.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 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,16 +13,18 @@ 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-tabs.js
     content/browser/ext-utils.js
     content/browser/ext-windows.js
     content/browser/ext-c-browserAction.js
     content/browser/ext-c-contextMenus.js
+    content/browser/ext-c-omnibox.js
     content/browser/ext-c-pageAction.js
     content/browser/ext-c-tabs.js
--- a/browser/components/extensions/schemas/jar.mn
+++ b/browser/components/extensions/schemas/jar.mn
@@ -4,11 +4,12 @@
 
 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/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
@@ -39,16 +39,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,280 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+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() {
+    yield extension.startup();
+    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();
+
+    // Select the heuristic result.  Due to bug 1309047, we currently
+    // have to hit down and then up in order to properly select the heuristic result.
+    EventUtils.synthesizeKey("VK_DOWN", {});
+    EventUtils.synthesizeKey("VK_UP", {});
+
+    // Click on the heuristic result.
+    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.
+
+      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 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 suggesions 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();
+});
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]
--- a/toolkit/components/places/ExtensionSearchHandler.jsm
+++ b/toolkit/components/places/ExtensionSearchHandler.jsm
@@ -119,21 +119,20 @@ var ExtensionSearchHandler = Object.free
       throw new Error(`The keyword provided is not registered: ${keyword}`);
     }
 
     if (keyword != gActiveKeyword) {
       throw new Error("A different input session is already ongoing");
     }
 
     if (id != gCurrentCallbackID) {
-      return false;
+      throw new Error("The callback is no longer active");
     }
 
     gSuggestionsCallback(suggestions);
-    return true;
   },
 
   /**
    * Called when the input in the urlbar begins with `<keyword><space>`.
    *
    * If the keyword is inactive, MSG_INPUT_STARTED is emitted and the
    * keyword is marked as active. If the keyword is followed by any text,
    * MSG_INPUT_CHANGED is fired with the current callback ID that can be
--- a/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js
+++ b/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js
@@ -36,31 +36,32 @@ add_task(function* test_correct_errors_a
   Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, "suggestion"));
 
   // Try calling handleInputCancelled when there is no active session.
   Assert.throws(() => ExtensionSearchHandler.handleInputCancelled());
 
   // Start a session by calling handleSearch with the registered keyword.
   ExtensionSearchHandler.handleSearch(keyword, `${keyword} test`, () => {});
 
-  // Try providing suggestions for an unregistered keyword.
-  Assert.throws(() => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 0, []));
-
-  // Try providing suggestions for an inactive keyword.
-  Assert.throws(() => ExtensionSearchHandler.addSuggestions(anotherKeyword, 0, []));
-
   // Try calling handleSearch for an inactive keyword
   Assert.throws(() => ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} `, () => {}));
 
-  // Try providing suggestions with inactive callback IDs.
-  Assert.ok(!ExtensionSearchHandler.addSuggestions(keyword, 0, []));
-  Assert.ok(!ExtensionSearchHandler.addSuggestions(keyword, 2, []));
+  // Try providing suggestions for an unregistered keyword.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(unregisteredKeyword, 1, []));
+
+  // Try providing suggestions for an inactive keyword.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(anotherKeyword, 1, []));
 
-  // Add suggestions for a valid callback ID.
-  Assert.ok(ExtensionSearchHandler.addSuggestions(keyword, 1, []));
+  // Try providing suggestions with an inactive callback ID.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 0, []));
+
+  // Try providing suggestions with another inactive callback ID.
+  Assert.throws(() => ExtensionSearchHandler.addSuggestions(keyword, 2, []));
+
+  ExtensionSearchHandler.addSuggestions(keyword, 1, []);
 
   // End the input session by calling handleInputCancelled.
   ExtensionSearchHandler.handleInputCancelled();
 
   // Try handling input after the session has ended using handleInputCancelled.
   Assert.throws(() => ExtensionSearchHandler.handleInputCancelled());
 
   // Start a new session by calling handleSearch with a different keyword