From dde16e219d74a11e471b5dce796b07ba04b99486 Mon Sep 17 00:00:00 2001 From: Matthew Wein Date: Thu, 3 Nov 2016 16:27:50 +0000 Subject: [PATCH] Bug 1267810 - Add a module for registering keywords and handling keyword input sessions. r=adw MozReview-Commit-ID: Ghqe5xLw67Y --HG-- extra : rebase_source : cae9a75edb18529f608bc68f464a432300b8b88e --- browser/base/content/browser.css | 10 +- browser/base/content/browser.xul | 3 +- browser/base/content/urlbarBindings.xml | 25 +- browser/components/nsBrowserGlue.js | 1 + .../locales/en-US/chrome/browser/browser.dtd | 1 + .../identity-block/identity-block.inc.css | 7 + .../places/ExtensionSearchHandler.jsm | 234 ++++++++++++ toolkit/components/places/UnifiedComplete.js | 76 +++- toolkit/components/places/moz.build | 1 + .../unifiedcomplete/head_autocomplete.js | 16 + .../unifiedcomplete/test_extension_matches.js | 341 ++++++++++++++++++ .../places/tests/unifiedcomplete/xpcshell.ini | 1 + toolkit/content/widgets/autocomplete.xml | 4 + 13 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 toolkit/components/places/ExtensionSearchHandler.jsm create mode 100644 toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js diff --git a/browser/base/content/browser.css b/browser/base/content/browser.css index 7bc89c0a6eb5..c9f5292c0008 100644 --- a/browser/base/content/browser.css +++ b/browser/base/content/browser.css @@ -472,7 +472,15 @@ toolbar:not(#TabsToolbar) > #personal-bookmarks { list-style-image: none; } -#urlbar:not([actiontype="switchtab"]) > #urlbar-display-box { +#urlbar:not([actiontype="switchtab"]):not([actiontype="extension"]) > #urlbar-display-box { + display: none; +} + +#urlbar:not([actiontype="switchtab"]) > #urlbar-display-box > #switchtab { + display: none; +} + +#urlbar:not([actiontype="extension"]) > #urlbar-display-box > #extension { display: none; } diff --git a/browser/base/content/browser.xul b/browser/base/content/browser.xul index 4c8822dc72c2..48a2dbf1efe1 100644 --- a/browser/base/content/browser.xul +++ b/browser/base/content/browser.xul @@ -760,7 +760,8 @@ - (Components.utils.import("resource://gre/modules/AppConstants.jsm", {})).AppConstants; + + + { + let searchHandler = {}; + Components.utils.import("resource://gre/modules/ExtensionSearchHandler.jsm", searchHandler); + searchHandler.ExtensionSearchHandler; + } + + + diff --git a/browser/themes/shared/identity-block/identity-block.inc.css b/browser/themes/shared/identity-block/identity-block.inc.css index d8627806649c..6fa40e9db41b 100644 --- a/browser/themes/shared/identity-block/identity-block.inc.css +++ b/browser/themes/shared/identity-block/identity-block.inc.css @@ -79,6 +79,13 @@ height: 16px; } +#urlbar[actiontype="extension"] > #identity-box > #identity-icon { + -moz-image-region: inherit; + list-style-image: url(chrome://browser/skin/addons/addon-install-anchor.svg); + width: 16px; + height: 16px; +} + /* SHARING ICON */ #sharing-icon { diff --git a/toolkit/components/places/ExtensionSearchHandler.jsm b/toolkit/components/places/ExtensionSearchHandler.jsm new file mode 100644 index 000000000000..1191a90020c2 --- /dev/null +++ b/toolkit/components/places/ExtensionSearchHandler.jsm @@ -0,0 +1,234 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = [ "ExtensionSearchHandler" ]; + +// Used to keep track of the active input session. +let gActiveKeyword = null; + +// Used to keep track of who has control over the active suggestion callback +// so others can be ignored. The callback ID should increment whenever the input +// changes or the input session ends. +let gCurrentCallbackID = 0; + +// The callback used to provided suggestions to the urlbar from the extension. +let gSuggestionsCallback = null; + +// Maps keywords to KeywordInfo instances. +let gKeywordMap = new Map(); + +// Stores information associated to a registered keyword. +class KeywordInfo { + constructor(extension) { + this.extension = extension; + + // The extension name is used as the default description + // for the heuristic result. + this.description = extension.name; + } +} + +var ExtensionSearchHandler = Object.freeze({ + MSG_INPUT_STARTED: "webext-omnibox-input-started", + MSG_INPUT_CHANGED: "webext-omnibox-input-changed", + MSG_INPUT_ENTERED: "webext-omnibox-input-entered", + MSG_INPUT_CANCELLED: "webext-omnibox-input-cancelled", + + /** + * Registers a keyword. + * + * @param {string} keyword The keyword to register. + * @param {Extension} extension The extension registering the keyword. + */ + registerKeyword(keyword, extension) { + if (gKeywordMap.has(keyword)) { + throw new Error(`The keyword provided is already registered: ${keyword}`); + } + gKeywordMap.set(keyword, new KeywordInfo(extension)); + }, + + /** + * Unregisters a keyword. + * + * @param {string} keyword The keyword to unregister. + */ + unregisterKeyword(keyword) { + if (!gKeywordMap.has(keyword)) { + throw new Error(`The keyword provided is not registered: ${keyword}`); + } + gActiveKeyword = null; + gKeywordMap.delete(keyword); + }, + + /** + * Checks if a keyword is registered. + * @param {string} keyword The word to check. + * @return {boolean} true if the word is a registered keyword. + */ + isKeywordRegistered(keyword) { + return gKeywordMap.has(keyword); + }, + + /** + * @param {string} keyword The keyword to look up. + * @return {string} the description to use for the heuristic result. + */ + getDescription(keyword) { + if (!gKeywordMap.has(keyword)) { + throw new Error(`The keyword provided is not registered: ${keyword}`); + } + return gKeywordMap.get(keyword).description; + }, + + /** + * @return {boolean} true if there is an input session is currently active. + */ + hasActiveInputSession() { + return gActiveKeyword != null; + }, + + /** + * Sets the default suggestion for the registered keyword. The suggestion's + * description will be used for the comment in the heuristic result. + * + * @param {string} keyword The keyword. + * @param {string} description The description to use for the heuristic result. + */ + setDefaultSuggestion(keyword, {description}) { + if (!gKeywordMap.has(keyword)) { + throw new Error(`The keyword provided is not registered: ${keyword}`); + } + gKeywordMap.get(keyword).description = description; + }, + + /** + * Adds suggestions for the registered keyword. This function will throw if + * the keyword provided is not registered or active, or if the callback ID + * provided is no longer equal to the active callback ID. + * + * @param {string} keyword The keyword. + * @param {integer} id The ID of the suggestion callback. + * @param {Array} suggestions An array of suggestions to provide to the urlbar. + * @return {boolean} true if a valid callback ID was provided; false otherwise. + */ + addSuggestions(keyword, id, suggestions) { + if (!gKeywordMap.has(keyword)) { + 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; + } + + gSuggestionsCallback(suggestions); + return true; + }, + + /** + * Called when the input in the urlbar begins with ``. + * + * 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 + * used to provide suggestions to the urlbar while the callback ID is active. + * The callback is invalidated when either the input changes or the urlbar blurs. + * + * @param {string} keyword The keyword to handle. + * @param {string} text The search text in the urlbar. + * @param {Function} callback The callback used to provide search suggestions. + */ + handleSearch(keyword, text, callback) { + if (!gKeywordMap.has(keyword)) { + throw new Error(`The keyword provided is not registered: ${keyword}`); + } + + if (gActiveKeyword && keyword != gActiveKeyword) { + throw new Error("A different input session is already ongoing"); + } + + if (!callback) { + throw new Error("A callback must be provided"); + } + + let {extension} = gKeywordMap.get(keyword); + + // Only emit MSG_INPUT_STARTED when the session first becomes active. + // This is different from Chrome's behavior, which fires MSG_INPUT_STARTED + // when MSG_INPUT_CHANGED first fires, but this is a bug in Chrome according + // to https://crbug.com/258911. + if (!gActiveKeyword) { + extension.emit(this.MSG_INPUT_STARTED); + } + + // The search text in the urlbar currently starts with , and + // we only want the text that follows. + text = text.substring(keyword.length + 1); + + // Only emit MSG_INPUT_CHANGED if the session is already an active + // session or there is text to process. + if (gActiveKeyword || text.length) { + // Increment the callback ID before emitting MSG_INPUT_CHANGED so + // the active callback ID is passed to the extension. + gCurrentCallbackID++; + gSuggestionsCallback = callback; + extension.emit(this.MSG_INPUT_CHANGED, text, gCurrentCallbackID); + } + + // Set the active keyword so we only emit MSG_INPUT_STARTED when the + // input session starts. + if (!gActiveKeyword) { + gActiveKeyword = keyword; + } + }, + + /** + * Called when the user submits a suggestion that was added by + * an extension. MSG_INPUT_ENTERED is emitted to the extension with + * the keyword, the current search string, and info about how the + * the search should be handled. In addition, the active keyword and + * active callback are reset to null. + * + * @param {string} keyword The keyword associated to the suggestion. + * @param {string} text The search text in the urlbar. + * @param {string} where Where the page should be opened. + */ + handleInputEntered(keyword, text, where) { + let dispositionMap = { + current: "currentTab", + tab: "newForegroundTab", + tabshifted: "newBackgroundTab", + } + let {extension} = gKeywordMap.get(keyword); + let disposition = dispositionMap[where] || dispositionMap.current; + + // The search text in the urlbar currently starts with , and + // we only want the text that follows. + text = text.substring(keyword.length + 1); + + gCurrentCallbackID++; + gActiveKeyword = null; + extension.emit(this.MSG_INPUT_ENTERED, text, disposition); + }, + + /** + * If the user has ended the keyword input session without accepting the input, + * MSG_INPUT_CANCELLED is emitted, and the active keyword and active callback + * are reset to null. + */ + handleInputCancelled() { + if (!gActiveKeyword) { + throw new Error("There is no active input session to handle"); + } + let {extension} = gKeywordMap.get(gActiveKeyword); + gCurrentCallbackID++; + gActiveKeyword = null; + extension.emit(this.MSG_INPUT_CANCELLED); + } +}); diff --git a/toolkit/components/places/UnifiedComplete.js b/toolkit/components/places/UnifiedComplete.js index a92fb615b613..7c6e3a793872 100644 --- a/toolkit/components/places/UnifiedComplete.js +++ b/toolkit/components/places/UnifiedComplete.js @@ -69,6 +69,11 @@ const FRECENCY_DEFAULT = 1000; // always try to have at least MINIMUM_LOCAL_MATCHES local matches. const MINIMUM_LOCAL_MATCHES = 6; +// Extensions are allowed to add suggestions if they have registered a keyword +// with the omnibox API. This is the maximum number of suggestions an extension +// is allowed to add for a given search string. +const MAXIMUM_ALLOWED_EXTENSION_MATCHES = 6; + // A regex that matches "single word" hostnames for whitelisting purposes. // The hostname will already have been checked for general validity, so we // don't need to be exhaustive here, so allow dashes anywhere. @@ -264,6 +269,8 @@ XPCOMUtils.defineLazyModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", "resource://gre/modules/PromiseUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionSearchHandler", + "resource://gre/modules/ExtensionSearchHandler.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "Task", "resource://gre/modules/Task.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PlacesSearchAutocompleteProvider", @@ -715,11 +722,15 @@ function Search(searchString, searchParam, autocompleteListener, // The index to insert remote matches at. this._remoteMatchesStartIndex = 0; + // The index to insert local matches at. + this._localMatchesStartIndex = 0; // Counts the number of inserted local matches. this._localMatchesCount = 0; // Counts the number of inserted remote matches. this._remoteMatchesCount = 0; + // Counts the number of inserted extension matches. + this._extensionMatchesCount = 0; } Search.prototype = { @@ -942,6 +953,17 @@ Search.prototype = { } } + // Only add extension suggestions if the first token is a registered keyword + // and the search string has characters after the first token. + if (ExtensionSearchHandler.isKeywordRegistered(this._searchTokens[0]) && + this._originalSearchString.length > this._searchTokens[0].length) { + // We don't yield here because, unlike the other calls, the extension can add + // suggestions until either the input changes or the urlbar blurs. + this._matchExtensionSuggestions(); + } else if (ExtensionSearchHandler.hasActiveInputSession()) { + ExtensionSearchHandler.handleInputCancelled(); + } + // Ensure to fill any remaining space. yield Promise.all(this._remoteMatchesPromises); }), @@ -950,7 +972,15 @@ Search.prototype = { // We always try to make the first result a special "heuristic" result. The // heuristics below determine what type of result it will be, if any. - let hasSearchTerms = this._searchTokens.length > 0 ; + let hasSearchTerms = this._searchTokens.length > 0; + + if (hasSearchTerms) { + // It may be a keyword registered by an extension. + let matched = yield this._matchExtensionHeuristicResult(); + if (matched) { + return true; + } + } if (this._enableActions && hasSearchTerms) { // It may be a search engine with an alias - which works like a keyword. @@ -1124,6 +1154,16 @@ Search.prototype = { return gotResult; }, + _matchExtensionHeuristicResult: function* () { + if (ExtensionSearchHandler.isKeywordRegistered(this._searchTokens[0]) && + this._originalSearchString.length > this._searchTokens[0].length) { + let description = ExtensionSearchHandler.getDescription(this._searchTokens[0]); + this._addExtensionMatch(this._originalSearchString, description); + return true; + } + return false; + }, + _matchPlacesKeyword: function* () { // The first word could be a keyword, so that's what we'll search. let keyword = this._searchTokens[0]; @@ -1236,6 +1276,24 @@ Search.prototype = { return true; }, + _addExtensionMatch(content, comment) { + if (this._extensionMatchesCount >= MAXIMUM_ALLOWED_EXTENSION_MATCHES) { + return; + } + + this._addMatch({ + value: PlacesUtils.mozActionURI("extension", { + content, + keyword: this._searchTokens[0] + }), + comment, + icon: "chrome://browser/content/extension.svg", + style: "action extension", + frecency: FRECENCY_DEFAULT, + extension: true, + }); + }, + _addSearchEngineMatch(match, query, suggestion) { let actionURLParams = { engineName: match.engineName, @@ -1259,6 +1317,17 @@ Search.prototype = { }); }, + _matchExtensionSuggestions() { + ExtensionSearchHandler.handleSearch(this._searchTokens[0], this._originalSearchString, + suggestions => { + suggestions.forEach(suggestion => { + let content = `${this._searchTokens[0]} ${suggestion.content}`; + this._addExtensionMatch(content, suggestion.description); + }); + } + ); + }, + *_matchRemoteTabs() { let matches = yield PlacesRemoteTabsAutocompleteProvider.getMatches(this._originalSearchString); for (let {url, title, icon, deviceClass, deviceName} of matches) { @@ -1464,6 +1533,11 @@ Search.prototype = { // Append after local matches. index = this._remoteMatchesStartIndex + this._remoteMatchesCount; this._remoteMatchesCount++; + } else if (match.extension) { + index = this._localMatchesStartIndex; + this._localMatchesStartIndex++; + this._remoteMatchesStartIndex++; + this._extensionMatchesCount++; } else { // This is a local match. if (match.frecency > FRECENCY_DEFAULT || diff --git a/toolkit/components/places/moz.build b/toolkit/components/places/moz.build index 55ab218ef885..adac79cba4cf 100644 --- a/toolkit/components/places/moz.build +++ b/toolkit/components/places/moz.build @@ -64,6 +64,7 @@ if CONFIG['MOZ_PLACES']: 'ClusterLib.js', 'ColorAnalyzer_worker.js', 'ColorConversion.js', + 'ExtensionSearchHandler.jsm', 'History.jsm', 'PlacesBackups.jsm', 'PlacesDBUtils.jsm', diff --git a/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js b/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js index 8bc221a746da..b85e70030eee 100644 --- a/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js +++ b/toolkit/components/places/tests/unifiedcomplete/head_autocomplete.js @@ -415,6 +415,22 @@ function makeSwitchToTabMatch(url, extra = {}) { } } +function makeExtensionMatch(extra = {}) { + let style = [ "action", "extension" ]; + if (extra.heuristic) { + style.push("heuristic"); + } + + return { + uri: makeActionURI("extension", { + content: extra.content, + keyword: extra.keyword, + }), + title: extra.description, + style, + }; +} + function setFaviconForHref(href, iconHref) { return new Promise(resolve => { PlacesUtils.favicons.setAndFetchFaviconForPage( diff --git a/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js b/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js new file mode 100644 index 000000000000..6c46b8ebf89b --- /dev/null +++ b/toolkit/components/places/tests/unifiedcomplete/test_extension_matches.js @@ -0,0 +1,341 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim:set ts=2 sw=2 sts=2 et: + * 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/. */ + +Cu.import("resource://gre/modules/ExtensionSearchHandler.jsm"); + +add_task(function* test_correct_errors_are_thrown() { + let keyword = "foo"; + let anotherKeyword = "bar"; + let unregisteredKeyword = "baz"; + + // Register a keyword. + ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} }); + + // Try registering the keyword again. + Assert.throws(() => ExtensionSearchHandler.registerKeyword(keyword, { emit: () => {} })); + + // Register a different keyword. + ExtensionSearchHandler.registerKeyword(anotherKeyword, { emit: () => {} }); + + // Try calling handleSearch for an unregistered keyword. + Assert.throws(() => ExtensionSearchHandler.handleSearch(unregisteredKeyword, `${unregisteredKeyword} `, () => {})); + + // Try calling handleSearch without a callback. + Assert.throws(() => ExtensionSearchHandler.handleSearch(unregisteredKeyword, `${unregisteredKeyword} `)); + + // Try getting the description for a keyword which isn't registered. + Assert.throws(() => ExtensionSearchHandler.getDescription(unregisteredKeyword)); + + // Try getting the extension name for a keyword which isn't registered. + Assert.throws(() => ExtensionSearchHandler.getExtensionName(unregisteredKeyword)); + + // Try setting the default suggestion for a keyword which isn't registered. + 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, [])); + + // Add suggestions for a valid callback ID. + Assert.ok(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 + ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} test`, () => {}); + + // Set the default suggestion. + ExtensionSearchHandler.setDefaultSuggestion(anotherKeyword, {description: "test result"}); + + // End the session by calling handleInputEntered. + ExtensionSearchHandler.handleInputEntered(anotherKeyword, `${anotherKeyword} test`, "tab"); + + // Try handling input after the session has ended using handleInputCancelled. + Assert.throws(() => ExtensionSearchHandler.handleInputCancelled()); + + // Unregister the keyword. + ExtensionSearchHandler.unregisterKeyword(keyword); + + // Try setting the default suggestion for the unregistered keyword. + Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(keyword, {description: "test"})); + + // Try handling a search with the unregistered keyword. + Assert.throws(() => ExtensionSearchHandler.handleSearch(keyword, `${keyword} test`, () => {})); + + // Try unregistering the keyword again. + Assert.throws(() => ExtensionSearchHandler.unregisterKeyword(keyword)); + + // Unregister the other keyword. + ExtensionSearchHandler.unregisterKeyword(anotherKeyword); + + // Try unregistering the word which was never registered. + Assert.throws(() => ExtensionSearchHandler.unregisterKeyword(unregisteredKeyword)); + + // Try setting the default suggestion for a word that was never registered. + Assert.throws(() => ExtensionSearchHandler.setDefaultSuggestion(unregisteredKeyword, {description: "test"})); + + yield cleanup(); +}); + +add_task(function* test_correct_events_are_emitted() { + let events = []; + function checkEvents(expectedEvents) { + Assert.equal(events.length, expectedEvents.length, "The correct number of events fired"); + expectedEvents.forEach((e, i) => Assert.equal(e, events[i], `Expected "${e}" event to fire`)); + events = []; + } + + let mockExtension = { emit: message => events.push(message) }; + + let keyword = "foo"; + let anotherKeyword = "bar"; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + ExtensionSearchHandler.registerKeyword(anotherKeyword, mockExtension); + + ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_STARTED]); + + ExtensionSearchHandler.handleSearch(keyword, `${keyword} f`, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_CHANGED]); + + ExtensionSearchHandler.handleInputEntered(keyword, `${keyword} f`, "tab"); + checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]); + + ExtensionSearchHandler.handleSearch(keyword, `${keyword} f`, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_STARTED, ExtensionSearchHandler.MSG_INPUT_CHANGED]); + + ExtensionSearchHandler.handleInputCancelled(); + checkEvents([ExtensionSearchHandler.MSG_INPUT_CANCELLED]); + + ExtensionSearchHandler.handleSearch(anotherKeyword, `${anotherKeyword} baz`, () => {}); + checkEvents([ExtensionSearchHandler.MSG_INPUT_STARTED, ExtensionSearchHandler.MSG_INPUT_CHANGED]); + + ExtensionSearchHandler.handleInputEntered(keyword, `${anotherKeyword} baz`, "tab"); + checkEvents([ExtensionSearchHandler.MSG_INPUT_ENTERED]); + + ExtensionSearchHandler.unregisterKeyword(keyword); +}); + +add_task(function* test_removes_suggestion_if_its_content_is_typed_in() { + let keyword = "test"; + let extensionName = "Foo Bar"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + {content: "foo", description: "first suggestion"}, + {content: "bar", description: "second suggestion"}, + {content: "baz", description: "third suggestion"}, + ]); + } + } + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + yield check_autocomplete({ + search: `${keyword} `, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} `}), + ] + }); + + yield check_autocomplete({ + search: `${keyword} unmatched`, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} unmatched`}), + makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"}) + ] + }); + + yield check_autocomplete({ + search: `${keyword} foo`, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} foo`}), + makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"}) + ] + }); + + yield check_autocomplete({ + search: `${keyword} bar`, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} bar`}), + makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"}) + ] + }); + + yield check_autocomplete({ + search: `${keyword} baz`, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} baz`}), + makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}) + ] + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + yield cleanup(); +}); + +add_task(function* test_extension_results_should_come_first() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let uri = NetUtil.newURI(`http://a.com/b`); + yield PlacesTestUtils.addVisits([ + { uri, title: `${keyword} -` }, + ]); + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + {content: "foo", description: "first suggestion"}, + {content: "bar", description: "second suggestion"}, + {content: "baz", description: "third suggestion"}, + ]); + } + } + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + // Start an input session before testing MSG_INPUT_CHANGED. + ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {}); + + yield check_autocomplete({ + search: `${keyword} -`, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} -`}), + makeExtensionMatch({keyword, content: `${keyword} foo`, description: "first suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} bar`, description: "second suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} baz`, description: "third suggestion"}), + { uri, title: `${keyword} -` } + ] + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + yield cleanup(); +}); + +add_task(function* test_setting_the_default_suggestion() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit() {} + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "hello world" + }); + + let searchString = `${keyword} search query`; + yield check_autocomplete({ + search: searchString, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: "hello world", content: searchString}), + ] + }); + + ExtensionSearchHandler.setDefaultSuggestion(keyword, { + description: "foo bar" + }); + + yield check_autocomplete({ + search: searchString, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: "foo bar", content: searchString}), + ] + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + yield cleanup(); +}); + +add_task(function* test_maximum_number_of_suggestions_is_enforced() { + let keyword = "test"; + let extensionName = "Omnibox Example"; + + let mockExtension = { + name: extensionName, + emit(message, text, id) { + if (message === ExtensionSearchHandler.MSG_INPUT_CHANGED) { + ExtensionSearchHandler.addSuggestions(keyword, id, [ + {content: "a", description: "first suggestion"}, + {content: "b", description: "second suggestion"}, + {content: "c", description: "third suggestion"}, + {content: "d", description: "fourth suggestion"}, + {content: "e", description: "fifth suggestion"}, + {content: "f", description: "sixth suggestion"}, + {content: "g", description: "seventh suggestion"}, + {content: "h", description: "eigth suggestion"}, + {content: "i", description: "ninth suggestion"}, + {content: "j", description: "tenth suggestion"}, + ]); + } + } + }; + + ExtensionSearchHandler.registerKeyword(keyword, mockExtension); + + // Start an input session before testing MSG_INPUT_CHANGED. + ExtensionSearchHandler.handleSearch(keyword, `${keyword} `, () => {}); + + yield check_autocomplete({ + search: `${keyword} #`, + searchParam: "enable-actions", + matches: [ + makeExtensionMatch({heuristic: true, keyword, description: extensionName, content: `${keyword} #`}), + makeExtensionMatch({keyword, content: `${keyword} a`, description: "first suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} b`, description: "second suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} c`, description: "third suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} d`, description: "fourth suggestion"}), + makeExtensionMatch({keyword, content: `${keyword} e`, description: "fifth suggestion"}), + ] + }); + + ExtensionSearchHandler.unregisterKeyword(keyword); + yield cleanup(); +}); diff --git a/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini b/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini index 81e1fe6c3c34..ca89ede9fbe3 100644 --- a/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini +++ b/toolkit/components/places/tests/unifiedcomplete/xpcshell.ini @@ -24,6 +24,7 @@ support-files = [test_empty_search.js] [test_enabled.js] [test_escape_self.js] +[test_extension_matches.js] [test_ignore_protocol.js] [test_keyword_search.js] [test_keyword_search_actions.js] diff --git a/toolkit/content/widgets/autocomplete.xml b/toolkit/content/widgets/autocomplete.xml index 9e29ee16c4fe..b05fcc362bf8 100644 --- a/toolkit/content/widgets/autocomplete.xml +++ b/toolkit/content/widgets/autocomplete.xml @@ -2125,6 +2125,10 @@ extends="chrome://global/content/bindings/popup.xml#popup"> titleLooksLikeUrl = true; let visitStr = this._stringBundle.GetStringFromName("visit"); this._setUpDescription(this._actionText, visitStr, true); + } else if (action.type == "extension") { + let content = action.params.content; + displayUrl = content; + this._setUpDescription(this._actionText, content, true); } }