From b32d909226b5ad2420ed7b3b7c5575e4dd0be90e Mon Sep 17 00:00:00 2001 From: Matthew Noorenberghe Date: Tue, 29 Jul 2014 11:28:35 -0700 Subject: [PATCH] Bug 1007979 - Refactor nsSearchSuggestions into a reusable JSM. r=adw Original JSM by mconnor. --- .../search/SearchSuggestionController.jsm | 349 +++++++++++++ toolkit/components/search/moz.build | 4 + .../components/search/nsSearchSuggestions.js | 421 ++-------------- .../tests/xpcshell/data/engineMaker.sjs | 63 +++ .../tests/xpcshell/data/searchSuggestions.sjs | 76 +++ .../tests/xpcshell/test_searchSuggest.js | 469 ++++++++++++++++++ .../search/tests/xpcshell/xpcshell.ini | 3 + 7 files changed, 997 insertions(+), 388 deletions(-) create mode 100644 toolkit/components/search/SearchSuggestionController.jsm create mode 100644 toolkit/components/search/tests/xpcshell/data/engineMaker.sjs create mode 100644 toolkit/components/search/tests/xpcshell/data/searchSuggestions.sjs create mode 100644 toolkit/components/search/tests/xpcshell/test_searchSuggest.js diff --git a/toolkit/components/search/SearchSuggestionController.jsm b/toolkit/components/search/SearchSuggestionController.jsm new file mode 100644 index 00000000000..e166c852890 --- /dev/null +++ b/toolkit/components/search/SearchSuggestionController.jsm @@ -0,0 +1,349 @@ +/* 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 = ["SearchSuggestionController"]; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Promise.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NS_ASSERT", "resource://gre/modules/debug.js"); + +const SEARCH_RESPONSE_SUGGESTION_JSON = "application/x-suggestions+json"; +const DEFAULT_FORM_HISTORY_PARAM = "searchbar-history"; +const HTTP_OK = 200; +const REMOTE_TIMEOUT = 500; // maximum time (ms) to wait before giving up on a remote suggestions + +/** + * SearchSuggestionController.jsm exists as a helper module to allow multiple consumers to request and display + * search suggestions from a given engine, regardless of the base implementation. Much of this + * code was originally in nsSearchSuggestions.js until it was refactored to separate it from the + * nsIAutoCompleteSearch dependency. + * One instance of SearchSuggestionController should be used per field since form history results are cached. + */ + +/** + * @param {function} [callback] - Callback for search suggestion results. You can use the promise + * returned by the search method instead if you prefer. + * @constructor + */ +this.SearchSuggestionController = function SearchSuggestionController(callback = null) { + this._callback = callback; +}; + +this.SearchSuggestionController.prototype = { + /** + * The maximum number of local form history results to return. + */ + maxLocalResults: 7, + + /** + * The maximum number of remote search engine results to return. + */ + maxRemoteResults: 10, + + // Private properties + /** + * The last form history result used to improve the performance of subsequent searches. + * This shouldn't be used for any other purpose as it is never cleared and therefore could be stale. + */ + _formHistoryResult: null, + + /** + * The remote server timeout timer, if applicable. The timer starts when form history + * search is completed. + */ + _remoteResultTimer: null, + + /** + * The deferred for the remote results before its promise is resolved. + */ + _deferredRemoteResult: null, + + /** + * The optional result callback registered from the constructor. + */ + _callback: null, + + /** + * The XMLHttpRequest object for remote results. + */ + _request: null, + + // Public methods + + /** + * Fetch search suggestions from all of the providers. Fetches in progress will be stopped and + * results from them will not be provided. + * + * @param {string} searchTerm - the term to provide suggestions for + * @param {bool} privateMode - whether the request is being made in the context of private browsing + * @param {nsISearchEngine} engine - search engine for the suggestions. + * + * @return {Promise} resolving to an object containing results or null. + */ + fetch: function(searchTerm, privateMode, engine) { + // There is no smart filtering from previous results here (as there is when looking through + // history/form data) because the result set returned by the server is different for every typed + // value - e.g. "ocean breathes" does not return a subset of the results returned for "ocean". + + this.stop(); + + if (!Services.search.isInitialized) { + throw new Error("Search not initialized yet (how did you get here?)"); + } + if (typeof privateMode === "undefined") { + throw new Error("The privateMode argument is required to avoid unintentional privacy leaks"); + } + if (!(engine instanceof Ci.nsISearchEngine)) { + throw new Error("Invalid search engine"); + } + if (!this.maxLocalResults && !this.maxRemoteResults) { + throw new Error("Zero results expected, what are you trying to do?"); + } + if (this.maxLocalResults < 0 || this.remoteResult < 0) { + throw new Error("Number of requested results must be positive"); + } + + // Array of promises to resolve before returning results. + let promises = []; + this._searchString = searchTerm; + + // Remote results + if (searchTerm && this.maxRemoteResults && + engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON)) { + this._deferredRemoteResult = this._fetchRemote(searchTerm, engine, privateMode); + promises.push(this._deferredRemoteResult.promise); + } + + // Local results from form history + if (this.maxLocalResults) { + let deferredHistoryResult = this._fetchFormHistory(searchTerm); + promises.push(deferredHistoryResult.promise); + } + + function handleRejection(reason) { + if (reason == "HTTP request aborted") { + // Do nothing since this is normal. + return null; + } + Cu.reportError("SearchSuggestionController rejection: " + reason); + return null; + } + return Promise.all(promises).then(this._dedupeAndReturnResults.bind(this), handleRejection); + }, + + /** + * Stop pending fetches so no results are returned from them. + * + * Note: If there was no remote results fetched, the fetching cannot be stopped and local results + * will still be returned because stopping relies on aborting the XMLHTTPRequest to reject the + * promise for Promise.all. + */ + stop: function() { + if (this._request) { + this._request.abort(); + } else if (!this.maxRemoteResults) { + Cu.reportError("SearchSuggestionController: Cannot stop fetching if remote results were not "+ + "requested"); + } + this._reset(); + }, + + // Private methods + + _fetchFormHistory: function(searchTerm) { + let deferredFormHistory = Promise.defer(); + + let acSearchObserver = { + // Implements nsIAutoCompleteSearch + onSearchResult: (search, result) => { + this._formHistoryResult = result; + + if (this._request) { + this._remoteResultTimer = Cc["@mozilla.org/timer;1"]. + createInstance(Ci.nsITimer); + this._remoteResultTimer.initWithCallback(this._onRemoteTimeout.bind(this), + REMOTE_TIMEOUT, + Ci.nsITimer.TYPE_ONE_SHOT); + } + + switch (result.searchResult) { + case Ci.nsIAutoCompleteResult.RESULT_SUCCESS: + case Ci.nsIAutoCompleteResult.RESULT_NOMATCH: + if (result.searchString !== this._searchString) { + deferredFormHistory.resolve("Unexpected response, this._searchString does not match form history response"); + return; + } + let fhEntries = []; + let maxHistoryItems = Math.min(result.matchCount, this.maxLocalResults); + for (let i = 0; i < maxHistoryItems; ++i) { + fhEntries.push(result.getValueAt(i)); + } + deferredFormHistory.resolve({ + result: fhEntries, + formHistoryResult: result, + }); + break; + case Ci.nsIAutoCompleteResult.RESULT_FAILURE: + case Ci.nsIAutoCompleteResult.RESULT_IGNORED: + deferredFormHistory.resolve("Form History returned RESULT_FAILURE or RESULT_IGNORED"); + break; + } + }, + }; + + let formHistory = Cc["@mozilla.org/autocomplete/search;1?name=form-history"]. + createInstance(Ci.nsIAutoCompleteSearch); + formHistory.startSearch(searchTerm, DEFAULT_FORM_HISTORY_PARAM, this._formHistoryResult, + acSearchObserver); + return deferredFormHistory; + }, + + /** + * Fetch suggestions from the search engine over the network. + */ + _fetchRemote: function(searchTerm, engine, privateMode) { + let deferredResponse = Promise.defer(); + this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. + createInstance(Ci.nsIXMLHttpRequest); + let submission = engine.getSubmission(searchTerm, + SEARCH_RESPONSE_SUGGESTION_JSON); + let method = (submission.postData ? "POST" : "GET"); + this._request.open(method, submission.uri.spec, true); + if (this._request.channel instanceof Ci.nsIPrivateBrowsingChannel) { + this._request.channel.setPrivate(privateMode); + } + this._request.mozBackgroundRequest = true; // suppress dialogs and fail silently + + this._request.addEventListener("load", this._onRemoteLoaded.bind(this, deferredResponse)); + this._request.addEventListener("error", (evt) => deferredResponse.resolve("HTTP error")); + // Reject for an abort assuming it's always from .stop() in which case we shouldn't return local + // or remote results for existing searches. + this._request.addEventListener("abort", (evt) => deferredResponse.reject("HTTP request aborted")); + + this._request.send(submission.postData); + + return deferredResponse; + }, + + /** + * Called when the request completed successfully (thought the HTTP status could be anything) + * so we can handle the response data. + * @private + */ + _onRemoteLoaded: function(deferredResponse) { + if (!this._request) { + deferredResponse.resolve("Got HTTP response after the request was cancelled"); + return; + } + + let status, serverResults; + try { + status = this._request.status; + } catch (e) { + // The XMLHttpRequest can throw NS_ERROR_NOT_AVAILABLE. + deferredResponse.resolve("Unknown HTTP status: " + e); + return; + } + + if (status != HTTP_OK || this._request.responseText == "") { + deferredResponse.resolve("Non-200 status or empty HTTP response: " + status); + return; + } + + try { + serverResults = JSON.parse(this._request.responseText); + } catch(ex) { + deferredResponse.resolve("Failed to parse suggestion JSON: " + ex); + return; + } + + if (this._searchString !== serverResults[0]) { + // something is wrong here so drop remote results + deferredResponse.resolve("Unexpected response, this._searchString does not match remote response"); + return; + } + let results = serverResults[1] || []; + deferredResponse.resolve({ result: results }); + }, + + /** + * Called when this._remoteResultTimer fires indicating the remote request took too long. + */ + _onRemoteTimeout: function () { + this._request = null; + + // FIXME: bug 387341 + // Need to break the cycle between us and the timer. + this._remoteResultTimer = null; + + // The XMLHTTPRequest for suggest results is taking too long + // so send out the form history results and cancel the request. + if (this._deferredRemoteResult) { + this._deferredRemoteResult.resolve("HTTP Timeout"); + this._deferredRemoteResult = null; + } + }, + + /** + * @param {Array} suggestResults - an array of result objects from different sources (local or remote) + * @return {Object} + */ + _dedupeAndReturnResults: function(suggestResults) { + NS_ASSERT(this._searchString !== null, "this._searchString shouldn't be null when returning results"); + let results = { + term: this._searchString, + remote: [], + local: [], + formHistoryResult: null, + }; + + for (let result of suggestResults) { + if (typeof result === "string") { // Failure message + Cu.reportError("SearchSuggestionController: " + result); + } else if (result.formHistoryResult) { // Local results have a formHistoryResult property. + results.formHistoryResult = result.formHistoryResult; + results.local = result.result || []; + } else { // Remote result + results.remote = result.result || []; + } + } + + // We don't want things to appear in both history and suggestions so remove entries from + // remote results that are alrady in local. + if (results.remote.length && results.local.length) { + for (let i = 0; i < results.local.length; ++i) { + let term = results.local[i]; + let dupIndex = results.remote.indexOf(term); + if (dupIndex != -1) { + results.remote.splice(dupIndex, 1); + } + } + } + + // Trim the number of results to the maximum requested (now that we've pruned dupes). + results.remote = results.remote.slice(0, this.maxRemoteResults); + + if (this._callback) { + this._callback(results); + } + this._reset(); + + return results; + }, + + _reset: function() { + this._request = null; + if (this._remoteResultTimer) { + this._remoteResultTimer.cancel(); + this._remoteResultTimer = null; + } + this._deferredRemoteResult = null; + this._searchString = null; + }, +}; diff --git a/toolkit/components/search/moz.build b/toolkit/components/search/moz.build index 56db0abfa65..3126a2e323f 100644 --- a/toolkit/components/search/moz.build +++ b/toolkit/components/search/moz.build @@ -11,6 +11,10 @@ EXTRA_COMPONENTS += [ 'toolkitsearch.manifest', ] +EXTRA_JS_MODULES += [ + 'SearchSuggestionController.jsm', +] + EXTRA_PP_COMPONENTS += [ 'nsSearchService.js', ] diff --git a/toolkit/components/search/nsSearchSuggestions.js b/toolkit/components/search/nsSearchSuggestions.js index cccd7114dcc..cbe7e5e6411 100644 --- a/toolkit/components/search/nsSearchSuggestions.js +++ b/toolkit/components/search/nsSearchSuggestions.js @@ -2,32 +2,24 @@ * 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/. */ -const SEARCH_RESPONSE_SUGGESTION_JSON = "application/x-suggestions+json"; - const BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled"; const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown"; const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; -const Cc = Components.classes; -const Ci = Components.interfaces; -const Cr = Components.results; -const Cu = Components.utils; - -const HTTP_OK = 200; -const HTTP_INTERNAL_SERVER_ERROR = 500; -const HTTP_BAD_GATEWAY = 502; -const HTTP_SERVICE_UNAVAILABLE = 503; +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm"); Cu.import("resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SearchSuggestionController", + "resource://gre/modules/SearchSuggestionController.jsm"); /** * SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch - * and can collect results for a given search by using the search URL supplied - * by the subclass. We do it this way since the AutoCompleteController in - * Mozilla requires a unique XPCOM Service for every search provider, even if - * the logic for two providers is identical. + * and can collect results for a given search by using this._suggestionController. + * We do it this way since the AutoCompleteController in Mozilla requires a + * unique XPCOM Service for every search provider, even if the logic for two + * providers is identical. * @constructor */ function SuggestAutoComplete() { @@ -38,6 +30,7 @@ SuggestAutoComplete.prototype = { _init: function() { this._addObservers(); this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); + this._suggestionController = new SearchSuggestionController(obj => this.onResultsReturned(obj)); }, get _suggestionLabel() { @@ -51,59 +44,6 @@ SuggestAutoComplete.prototype = { */ _suggestEnabled: null, - /************************************************************************* - * Server request backoff implementation fields below - * These allow us to throttle requests if the server is getting hammered. - **************************************************************************/ - - /** - * This is an array that contains the timestamps (in unixtime) of - * the last few backoff-triggering errors. - */ - _serverErrorLog: [], - - /** - * If we receive this number of backoff errors within the amount of time - * specified by _serverErrorPeriod, then we initiate backoff. - */ - _maxErrorsBeforeBackoff: 3, - - /** - * If we receive enough consecutive errors (where "enough" is defined by - * _maxErrorsBeforeBackoff above) within this time period, - * we trigger the backoff behavior. - */ - _serverErrorPeriod: 600000, // 10 minutes in milliseconds - - /** - * If we get another backoff error immediately after timeout, we increase the - * backoff to (2 x old period) + this value. - */ - _serverErrorTimeoutIncrement: 600000, // 10 minutes in milliseconds - - /** - * The current amount of time to wait before trying a server request - * after receiving a backoff error. - */ - _serverErrorTimeout: 0, - - /** - * Time (in unixtime) after which we're allowed to try requesting again. - */ - _nextRequestTime: 0, - - /** - * The last engine we requested against (so that we can tell if the - * user switched engines). - */ - _serverErrorEngine: null, - - /** - * The XMLHttpRequest object. - * @private - */ - _request: null, - /** * The object implementing nsIAutoCompleteObserver that we notify when * we have found results @@ -111,81 +51,6 @@ SuggestAutoComplete.prototype = { */ _listener: null, - /** - * If this is true, we'll integrate form history results with the - * suggest results. - */ - _includeFormHistory: true, - - /** - * True if a request for remote suggestions was sent. This is used to - * differentiate between the "_request is null because the request has - * already returned a result" and "_request is null because no request was - * sent" cases. - */ - _sentSuggestRequest: false, - - /** - * This is the callback for the suggest timeout timer. - */ - notify: function SAC_notify(timer) { - // FIXME: bug 387341 - // Need to break the cycle between us and the timer. - this._formHistoryTimer = null; - - // If this._listener is null, we've already sent out suggest results, so - // nothing left to do here. - if (!this._listener) - return; - - // Otherwise, the XMLHTTPRequest for suggest results is taking too long, - // so send out the form history results and cancel the request. - this._listener.onSearchResult(this, this._formHistoryResult); - this._reset(); - }, - - /** - * This determines how long (in ms) we should wait before giving up on - * the suggestions and just showing local form history results. - */ - _suggestionTimeout: 500, - - /** - * This is the callback for that the form history service uses to - * send us results. - */ - onSearchResult: function SAC_onSearchResult(search, result) { - this._formHistoryResult = result; - - if (this._request) { - // We still have a pending request, wait a bit to give it a chance to - // finish. - this._formHistoryTimer = Cc["@mozilla.org/timer;1"]. - createInstance(Ci.nsITimer); - this._formHistoryTimer.initWithCallback(this, this._suggestionTimeout, - Ci.nsITimer.TYPE_ONE_SHOT); - } else if (!this._sentSuggestRequest) { - // We didn't send a request, so just send back the form history results. - this._listener.onSearchResult(this, this._formHistoryResult); - this._reset(); - } - }, - - /** - * This is the URI that the last suggest request was sent to. - */ - _suggestURI: null, - - /** - * Autocomplete results from the form history service get stored here. - */ - _formHistoryResult: null, - - /** - * This holds the suggest server timeout timer, if applicable. - */ - _formHistoryTimer: null, - /** * Maximum number of history items displayed. This is capped at 7 * because the primary consumer (Firefox search bar) displays 10 rows @@ -195,176 +60,32 @@ SuggestAutoComplete.prototype = { _historyLimit: 7, /** - * This clears all the per-request state. - */ - _reset: function SAC_reset() { - // Don't let go of our listener and form history result if the timer is - // still pending, the timer will call _reset() when it fires. - if (!this._formHistoryTimer) { - this._listener = null; - this._formHistoryResult = null; - } - this._request = null; - }, - - /** - * This sends an autocompletion request to the form history service, - * which will call onSearchResults with the results of the query. - */ - _startHistorySearch: function SAC_SHSearch(searchString, searchParam) { - var formHistory = - Cc["@mozilla.org/autocomplete/search;1?name=form-history"]. - createInstance(Ci.nsIAutoCompleteSearch); - formHistory.startSearch(searchString, searchParam, this._formHistoryResult, this); - }, - - /** - * Makes a note of the fact that we've received a backoff-triggering - * response, so that we can adjust the backoff behavior appropriately. - */ - _noteServerError: function SAC__noteServeError() { - var currentTime = Date.now(); - - this._serverErrorLog.push(currentTime); - if (this._serverErrorLog.length > this._maxErrorsBeforeBackoff) - this._serverErrorLog.shift(); - - if ((this._serverErrorLog.length == this._maxErrorsBeforeBackoff) && - ((currentTime - this._serverErrorLog[0]) < this._serverErrorPeriod)) { - // increase timeout, and then don't request until timeout is over - this._serverErrorTimeout = (this._serverErrorTimeout * 2) + - this._serverErrorTimeoutIncrement; - this._nextRequestTime = currentTime + this._serverErrorTimeout; - } - }, - - /** - * Resets the backoff behavior; called when we get a successful response. - */ - _clearServerErrors: function SAC__clearServerErrors() { - this._serverErrorLog = []; - this._serverErrorTimeout = 0; - this._nextRequestTime = 0; - }, - - /** - * This checks whether we should send a server request (i.e. we're not - * in a error-triggered backoff period. - * + * Callback for handling results from SearchSuggestionController.jsm * @private */ - _okToRequest: function SAC__okToRequest() { - return Date.now() > this._nextRequestTime; - }, + onResultsReturned: function(results) { + let finalResults = []; + let finalComments = []; - /** - * This checks to see if the new search engine is different - * from the previous one, and if so clears any error state that might - * have accumulated for the old engine. - * - * @param engine The engine that the suggestion request would be sent to. - * @private - */ - _checkForEngineSwitch: function SAC__checkForEngineSwitch(engine) { - if (engine == this._serverErrorEngine) - return; - - // must've switched search providers, clear old errors - this._serverErrorEngine = engine; - this._clearServerErrors(); - }, - - /** - * This returns true if the status code of the HTTP response - * represents a backoff-triggering error. - * - * @param status The status code from the HTTP response - * @private - */ - _isBackoffError: function SAC__isBackoffError(status) { - return ((status == HTTP_INTERNAL_SERVER_ERROR) || - (status == HTTP_BAD_GATEWAY) || - (status == HTTP_SERVICE_UNAVAILABLE)); - }, - - /** - * Called when the 'readyState' of the XMLHttpRequest changes. We only care - * about state 4 (COMPLETED) - handle the response data. - * @private - */ - onReadyStateChange: function() { - // xxx use the real const here - if (!this._request || this._request.readyState != 4) - return; - - try { - var status = this._request.status; - } catch (e) { - // The XML HttpRequest can throw NS_ERROR_NOT_AVAILABLE. - return; + // If form history has results, add them to the list. + let maxHistoryItems = Math.min(results.local.length, this._historyLimit); + for (let i = 0; i < maxHistoryItems; ++i) { + finalResults.push(results.local[i]); + finalComments.push(""); } - if (this._isBackoffError(status)) { - this._noteServerError(); - return; - } - - var responseText = this._request.responseText; - if (status != HTTP_OK || responseText == "") - return; - - this._clearServerErrors(); - - try { - var serverResults = JSON.parse(responseText); - } catch(ex) { - Components.utils.reportError("Failed to parse JSON from " + this._suggestURI.spec + ": " + ex); - return; - } - - var searchString = serverResults[0] || ""; - var results = serverResults[1] || []; - - var comments = []; // "comments" column values for suggestions - var historyResults = []; - var historyComments = []; - - // If form history is enabled and has results, add them to the list. - if (this._includeFormHistory && this._formHistoryResult && - (this._formHistoryResult.searchResult == - Ci.nsIAutoCompleteResult.RESULT_SUCCESS)) { - var maxHistoryItems = Math.min(this._formHistoryResult.matchCount, this._historyLimit); - for (var i = 0; i < maxHistoryItems; ++i) { - var term = this._formHistoryResult.getValueAt(i); - - // we don't want things to appear in both history and suggestions - var dupIndex = results.indexOf(term); - if (dupIndex != -1) - results.splice(dupIndex, 1); - - historyResults.push(term); - historyComments.push(""); - } - } - - // fill out the comment column for the suggestions - for (var i = 0; i < results.length; ++i) - comments.push(""); - - // if we have any suggestions, put a label at the top - if (comments.length > 0) + // If there are remote matches, add them. + if (results.remote.length) { + // "comments" column values for suggestions starts as empty strings + let comments = new Array(results.remote.length).fill("", 1); comments[0] = this._suggestionLabel; - - // now put the history results above the suggestions - var finalResults = historyResults.concat(results); - var finalComments = historyComments.concat(comments); + // now put the history results above the suggestions + finalResults = finalResults.concat(results.remote); + finalComments = finalComments.concat(comments); + } // Notify the FE of our new results - this.onResultsReady(searchString, finalResults, finalComments, - this._formHistoryResult); - - // Reset our state for next time. - this._reset(); + this.onResultsReady(results.term, finalResults, finalComments, results.formHistoryResult); }, /** @@ -374,10 +95,9 @@ SuggestAutoComplete.prototype = { * @param comments an array of metadata corresponding to the results * @private */ - onResultsReady: function(searchString, results, comments, - formHistoryResult) { + onResultsReady: function(searchString, results, comments, formHistoryResult) { if (this._listener) { - var result = new FormAutoCompleteResult( + let result = new FormAutoCompleteResult( searchString, Ci.nsIAutoCompleteResult.RESULT_SUCCESS, 0, @@ -389,8 +109,7 @@ SuggestAutoComplete.prototype = { this._listener.onSearchResult(this, result); - // Null out listener to make sure we don't notify it twice, in case our - // timer callback still hasn't run. + // Null out listener to make sure we don't notify it twice this._listener = null; } }, @@ -445,56 +164,10 @@ SuggestAutoComplete.prototype = { * Actual implementation of search. */ _triggerSearch: function(searchString, searchParam, listener, privacyMode) { - // If there's an existing request, stop it. There is no smart filtering - // here as there is when looking through history/form data because the - // result set returned by the server is different for every typed value - - // "ocean breathes" does not return a subset of the results returned for - // "ocean", for example. This does nothing if there is no current request. - this.stopSearch(); - this._listener = listener; - - var engine = Services.search.currentEngine; - - this._checkForEngineSwitch(engine); - - if (!searchString || - !this._suggestEnabled || - !engine.supportsResponseType(SEARCH_RESPONSE_SUGGESTION_JSON) || - !this._okToRequest()) { - // We have an empty search string (user pressed down arrow to see - // history), or search suggestions are disabled, or the current engine - // has no suggest functionality, or we're in backoff mode; so just use - // local history. - this._sentSuggestRequest = false; - this._startHistorySearch(searchString, searchParam); - return; - } - - // Actually do the search - this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. - createInstance(Ci.nsIXMLHttpRequest); - var submission = engine.getSubmission(searchString, - SEARCH_RESPONSE_SUGGESTION_JSON); - this._suggestURI = submission.uri; - var method = (submission.postData ? "POST" : "GET"); - this._request.open(method, this._suggestURI.spec, true); - this._request.channel.notificationCallbacks = new AuthPromptOverride(); - if (this._request.channel instanceof Ci.nsIPrivateBrowsingChannel) { - this._request.channel.setPrivate(privacyMode); - } - - var self = this; - function onReadyStateChange() { - self.onReadyStateChange(); - } - this._request.onreadystatechange = onReadyStateChange; - this._request.send(submission.postData); - - if (this._includeFormHistory) { - this._sentSuggestRequest = true; - this._startHistorySearch(searchString, searchParam); - } + this._suggestionController.fetch(searchString, + privacyMode, + Services.search.currentEngine); }, /** @@ -502,10 +175,7 @@ SuggestAutoComplete.prototype = { * implementation. */ stopSearch: function() { - if (this._request) { - this._request.abort(); - this._reset(); - } + this._suggestionController.stop(); }, /** @@ -539,31 +209,6 @@ SuggestAutoComplete.prototype = { Ci.nsIAutoCompleteObserver]) }; -function AuthPromptOverride() { -} -AuthPromptOverride.prototype = { - // nsIAuthPromptProvider - getAuthPrompt: function (reason, iid) { - // Return a no-op nsIAuthPrompt2 implementation. - return { - promptAuth: function () { - throw Cr.NS_ERROR_NOT_IMPLEMENTED; - }, - asyncPromptAuth: function () { - throw Cr.NS_ERROR_NOT_IMPLEMENTED; - } - }; - }, - - // nsIInterfaceRequestor - getInterface: function SSLL_getInterface(iid) { - return this.QueryInterface(iid); - }, - - // nsISupports - QueryInterface: XPCOMUtils.generateQI([Ci.nsIAuthPromptProvider, - Ci.nsIInterfaceRequestor]) -}; /** * SearchSuggestAutoComplete is a service implementation that handles suggest * results specific to web searches. diff --git a/toolkit/components/search/tests/xpcshell/data/engineMaker.sjs b/toolkit/components/search/tests/xpcshell/data/engineMaker.sjs new file mode 100644 index 00000000000..285737c26df --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engineMaker.sjs @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +/** + * Dynamically create a search engine offering search suggestions via searchSuggestions.sjs. + * + * The engine is constructed by passing a JSON object with engine datails as the query string. + */ + +function handleRequest(request, response) { + let engineData = JSON.parse(unescape(request.queryString).replace("+", " ")); + + if (!engineData.baseURL) { + response.setStatusLine(request.httpVersion, 500, "baseURL required"); + return; + } + + engineData.engineType = engineData.engineType || Ci.nsISearchEngine.TYPE_OPENSEARCH; + engineData.name = engineData.name || "Generated test engine"; + engineData.description = engineData.description || "Generated test engine description"; + engineData.method = engineData.method || "GET"; + + response.setStatusLine(request.httpVersion, 200, "OK"); + + switch (engineData.engineType) { + case Ci.nsISearchEngine.TYPE_OPENSEARCH: + createOpenSearchEngine(response, engineData); + break; + default: + response.setStatusLine(request.httpVersion, 404, "Unsupported engine type"); + break; + } +} + +/** + * Create an OpenSearch engine for the given base URL. + */ +function createOpenSearchEngine(response, engineData) { + let params = "", queryString = ""; + if (engineData.method == "POST") { + params = ""; + } else { + queryString = "?q={searchTerms}"; + } + + let result = "\ +\ + " + engineData.name + "\ + " + engineData.description + "\ + UTF-8\ + " + engineData.name + "\ + \ + " + params + "\ + \ + \ +\ +"; + response.write(result); +} diff --git a/toolkit/components/search/tests/xpcshell/data/searchSuggestions.sjs b/toolkit/components/search/tests/xpcshell/data/searchSuggestions.sjs new file mode 100644 index 00000000000..484c8f11f1b --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/searchSuggestions.sjs @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); + +/** + * Provide search suggestions in the OpenSearch JSON format. + */ + +function handleRequest(request, response) { + // Get the query parameters from the query string. + let query = parseQueryString(request.queryString); + + function writeSuggestions(query, completions = []) { + let result = [query, completions]; + response.write(JSON.stringify(result)); + return result; + } + + response.setStatusLine(request.httpVersion, 200, "OK"); + + let q = request.method == "GET" ? query.q : undefined; + if (q == "no remote" || q == "no results") { + writeSuggestions(q); + } else if (q == "Query Mismatch") { + writeSuggestions("This is an incorrect query string", ["some result"]); + } else if (q == "") { + writeSuggestions("", ["The server should never be sent an empty query"]); + } else if (q && q.startsWith("mo")) { + writeSuggestions(q, ["Mozilla", "modern", "mom"]); + } else if (q && q.startsWith("I ❤️")) { + writeSuggestions(q, ["I ❤️ Mozilla"]); + } else if (q && q.startsWith("letter ")) { + let letters = []; + for (let charCode = "A".charCodeAt(); charCode <= "Z".charCodeAt(); charCode++) { + letters.push("letter " + String.fromCharCode(charCode)); + } + writeSuggestions(q, letters); + } else if (q && q.startsWith("HTTP ")) { + response.setStatusLine(request.httpVersion, q.replace("HTTP ", ""), q); + writeSuggestions(q, [q]); + } else if (q && q.startsWith("delay")) { + // Delay the response by 200 milliseconds (less than the timeout but hopefully enough to abort + // before completion). + response.processAsync(); + writeSuggestions(q, [q]); + setTimeout(() => response.finish(), 200); + } else if (q && q.startsWith("slow ")) { + // Delay the response by 10 seconds so the client timeout is reached. + response.processAsync(); + writeSuggestions(q, [q]); + setTimeout(() => response.finish(), 10000); + } else if (request.method == "POST") { + // This includes headers, not just the body + let requestText = NetUtil.readInputStreamToString(request.bodyInputStream, + request.bodyInputStream.available()); + // Only use the last line which contains the encoded params + let requestLines = requestText.split("\n"); + let postParams = parseQueryString(requestLines[requestLines.length - 1]); + writeSuggestions(postParams.q, ["Mozilla", "modern", "mom"]); + } else { + response.setStatusLine(request.httpVersion, 404, "Not Found"); + } +} + +function parseQueryString(queryString) { + let query = {}; + queryString.split('&').forEach(function (val) { + let [name, value] = val.split('='); + query[name] = unescape(value).replace("+", " "); + }); + return query; +} diff --git a/toolkit/components/search/tests/xpcshell/test_searchSuggest.js b/toolkit/components/search/tests/xpcshell/test_searchSuggest.js new file mode 100644 index 00000000000..36ea60dc930 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest.js @@ -0,0 +1,469 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Testing search suggestions from SearchSuggestionController.jsm. + */ + +"use strict"; + +Cu.import("resource://gre/modules/FormHistory.jsm"); +Cu.import("resource://gre/modules/SearchSuggestionController.jsm"); +Cu.import("resource://gre/modules/Timer.jsm"); + +let httpServer = new HttpServer(); +let getEngine, postEngine, unresolvableEngine; + +function run_test() { + removeMetadata(); + updateAppInfo(); + + let httpServer = useHttpServer(); + httpServer.registerContentType("sjs", "sjs"); + + do_register_cleanup(() => Task.spawn(function* cleanup() { + // Remove added form history entries + yield updateSearchHistory("remove", null); + FormHistory.shutdown(); + })); + + run_next_test(); +} + +add_task(function* add_test_engines() { + let getEngineData = { + baseURL: gDataUrl, + engineType: Ci.nsISearchEngine.TYPE_OPENSEARCH, + name: "GET suggestion engine", + method: "GET", + }; + + let postEngineData = { + baseURL: gDataUrl, + engineType: Ci.nsISearchEngine.TYPE_OPENSEARCH, + name: "POST suggestion engine", + method: "POST", + }; + + let unresolvableEngineData = { + baseURL: "http://example.invalid/", + engineType: Ci.nsISearchEngine.TYPE_OPENSEARCH, + name: "Offline suggestion engine", + method: "GET", + }; + + [getEngine, postEngine, unresolvableEngine] = yield addTestEngines([ + { + name: getEngineData.name, + xmlFileName: "engineMaker.sjs?" + JSON.stringify(getEngineData), + }, + { + name: postEngineData.name, + xmlFileName: "engineMaker.sjs?" + JSON.stringify(postEngineData), + }, + { + name: unresolvableEngineData.name, + xmlFileName: "engineMaker.sjs?" + JSON.stringify(unresolvableEngineData), + }, + ]); +}); + + +// Begin tests + +add_task(function* simple_no_result_callback() { + let deferred = Promise.defer(); + let controller = new SearchSuggestionController((result) => { + do_check_eq(result.term, "no remote"); + do_check_eq(result.local.length, 0); + do_check_eq(result.remote.length, 0); + deferred.resolve(); + }); + + controller.fetch("no remote", false, getEngine); + yield deferred.promise; +}); + +add_task(function* simple_no_result_callback_and_promise() { + // Make sure both the callback and promise get results + let deferred = Promise.defer(); + let controller = new SearchSuggestionController((result) => { + do_check_eq(result.term, "no results"); + do_check_eq(result.local.length, 0); + do_check_eq(result.remote.length, 0); + deferred.resolve(); + }); + + let result = yield controller.fetch("no results", false, getEngine); + do_check_eq(result.term, "no results"); + do_check_eq(result.local.length, 0); + do_check_eq(result.remote.length, 0); + + yield deferred.promise; +}); + +add_task(function* simple_no_result_promise() { + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("no remote", false, getEngine); + do_check_eq(result.term, "no remote"); + do_check_eq(result.local.length, 0); + do_check_eq(result.remote.length, 0); +}); + +add_task(function* simple_remote_no_local_result() { + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("mo", false, getEngine); + do_check_eq(result.term, "mo"); + do_check_eq(result.local.length, 0); + do_check_eq(result.remote.length, 3); + do_check_eq(result.remote[0], "Mozilla"); + do_check_eq(result.remote[1], "modern"); + do_check_eq(result.remote[2], "mom"); +}); + +add_task(function* simple_local_no_remote_result() { + yield updateSearchHistory("bump", "no remote entries"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("no remote", false, getEngine); + do_check_eq(result.term, "no remote"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "no remote entries"); + do_check_eq(result.remote.length, 0); + + yield updateSearchHistory("remove", "no remote entries"); +}); + +add_task(function* simple_non_ascii() { + yield updateSearchHistory("bump", "I ❤️ XUL"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("I ❤️", false, getEngine); + do_check_eq(result.term, "I ❤️"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "I ❤️ XUL"); + do_check_eq(result.remote.length, 1); + do_check_eq(result.remote[0], "I ❤️ Mozilla"); +}); + + +add_task(function* both_local_remote_result_dedupe() { + yield updateSearchHistory("bump", "Mozilla"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("mo", false, getEngine); + do_check_eq(result.term, "mo"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "Mozilla"); + do_check_eq(result.remote.length, 2); + do_check_eq(result.remote[0], "modern"); + do_check_eq(result.remote[1], "mom"); +}); + +add_task(function* POST_both_local_remote_result_dedupe() { + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("mo", false, postEngine); + do_check_eq(result.term, "mo"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "Mozilla"); + do_check_eq(result.remote.length, 2); + do_check_eq(result.remote[0], "modern"); + do_check_eq(result.remote[1], "mom"); +}); + +add_task(function* both_local_remote_result_dedupe2() { + yield updateSearchHistory("bump", "mom"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("mo", false, getEngine); + do_check_eq(result.term, "mo"); + do_check_eq(result.local.length, 2); + do_check_eq(result.local[0], "mom"); + do_check_eq(result.local[1], "Mozilla"); + do_check_eq(result.remote.length, 1); + do_check_eq(result.remote[0], "modern"); +}); + +add_task(function* both_local_remote_result_dedupe3() { + // All of the server entries also exist locally + yield updateSearchHistory("bump", "modern"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("mo", false, getEngine); + do_check_eq(result.term, "mo"); + do_check_eq(result.local.length, 3); + do_check_eq(result.local[0], "modern"); + do_check_eq(result.local[1], "mom"); + do_check_eq(result.local[2], "Mozilla"); + do_check_eq(result.remote.length, 0); +}); + +add_task(function* fetch_twice_in_a_row() { + // Two entries since the first will match the first fetch but not the second. + yield updateSearchHistory("bump", "delay local"); + yield updateSearchHistory("bump", "delayed local"); + + let controller = new SearchSuggestionController(); + let resultPromise1 = controller.fetch("delay", false, getEngine); + + // A second fetch while the server is still waiting to return results leads to an abort. + let resultPromise2 = controller.fetch("delayed ", false, getEngine); + yield resultPromise1.then((results) => do_check_null(results)); + + let result = yield resultPromise2; + do_check_eq(result.term, "delayed "); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "delayed local"); + do_check_eq(result.remote.length, 1); + do_check_eq(result.remote[0], "delayed "); +}); + +add_task(function* fetch_twice_subset_reuse_formHistoryResult() { + // This tests if we mess up re-using the cached form history result. + // Two entries since the first will match the first fetch but not the second. + yield updateSearchHistory("bump", "delay local"); + yield updateSearchHistory("bump", "delayed local"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("delay", false, getEngine); + do_check_eq(result.term, "delay"); + do_check_eq(result.local.length, 2); + do_check_eq(result.local[0], "delay local"); + do_check_eq(result.local[1], "delayed local"); + do_check_eq(result.remote.length, 1); + do_check_eq(result.remote[0], "delay"); + + // Remove the entry from the DB but it should remain in the cached formHistoryResult. + yield updateSearchHistory("remove", "delayed local"); + + let result2 = yield controller.fetch("delayed ", false, getEngine); + do_check_eq(result2.term, "delayed "); + do_check_eq(result2.local.length, 1); + do_check_eq(result2.local[0], "delayed local"); + do_check_eq(result2.remote.length, 1); + do_check_eq(result2.remote[0], "delayed "); +}); + +add_task(function* both_identical_with_more_than_max_results() { + // Add letters A through Z to form history which will match the server + for (let charCode = "A".charCodeAt(); charCode <= "Z".charCodeAt(); charCode++) { + yield updateSearchHistory("bump", "letter " + String.fromCharCode(charCode)); + } + + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 7; + controller.maxRemoteResults = 10; + let result = yield controller.fetch("letter ", false, getEngine); + do_check_eq(result.term, "letter "); + do_check_eq(result.local.length, 7); + for (let i = 0; i < controller.maxLocalResults; i++) { + do_check_eq(result.local[i], "letter " + String.fromCharCode("A".charCodeAt() + i)); + } + do_check_eq(result.remote.length, 10); + for (let i = 0; i < controller.maxRemoteResults; i++) { + do_check_eq(result.remote[i], + "letter " + String.fromCharCode("A".charCodeAt() + controller.maxLocalResults + i)); + } +}); + +add_task(function* one_of_each() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 1; + controller.maxRemoteResults = 1; + let result = yield controller.fetch("letter ", false, getEngine); + do_check_eq(result.term, "letter "); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "letter A"); + do_check_eq(result.remote.length, 1); + do_check_eq(result.remote[0], "letter B"); +}); + +add_task(function* one_local_zero_remote() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 1; + controller.maxRemoteResults = 0; + let result = yield controller.fetch("letter ", false, getEngine); + do_check_eq(result.term, "letter "); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "letter A"); + do_check_eq(result.remote.length, 0); +}); + +add_task(function* zero_local_one_remote() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 0; + controller.maxRemoteResults = 1; + let result = yield controller.fetch("letter ", false, getEngine); + do_check_eq(result.term, "letter "); + do_check_eq(result.local.length, 0); + do_check_eq(result.remote.length, 1); + do_check_eq(result.remote[0], "letter A"); +}); + +add_task(function* stop_search() { + let controller = new SearchSuggestionController((result) => { + do_throw("The callback shouldn't be called after stop()"); + }); + let resultPromise = controller.fetch("mo", false, getEngine); + controller.stop(); + yield resultPromise.then((result) => { + do_check_null(result); + }); +}); + +add_task(function* empty_searchTerm() { + // Empty searches don't go to the server but still get form history. + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("", false, getEngine); + do_check_eq(result.term, ""); + do_check_true(result.local.length > 0); + do_check_eq(result.remote.length, 0); +}); + +add_task(function* slow_timeout() { + let d = Promise.defer(); + function check_result(result) { + do_check_eq(result.term, "slow "); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "slow local result"); + do_check_eq(result.remote.length, 0); + } + yield updateSearchHistory("bump", "slow local result"); + + let controller = new SearchSuggestionController(); + setTimeout(function check_timeout() { + // The HTTP response takes 10 seconds so check that we already have results after 2 seconds. + check_result(result); + d.resolve(); + }, 2000); + let result = yield controller.fetch("slow ", false, getEngine); + check_result(result); + yield d.promise; +}); + +add_task(function* slow_stop() { + let d = Promise.defer(); + let controller = new SearchSuggestionController(); + let resultPromise = controller.fetch("slow ", false, getEngine); + setTimeout(function check_timeout() { + // The HTTP response takes 10 seconds but we timeout in less than a second so just use 0. + controller.stop(); + d.resolve(); + }, 0); + yield resultPromise.then((result) => { + do_check_null(result); + }); + + yield d.promise; +}); + + +// Error handling + +add_task(function* remote_term_mismatch() { + yield updateSearchHistory("bump", "Query Mismatch Entry"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("Query Mismatch", false, getEngine); + do_check_eq(result.term, "Query Mismatch"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "Query Mismatch Entry"); + do_check_eq(result.remote.length, 0); +}); + +add_task(function* http_404() { + yield updateSearchHistory("bump", "HTTP 404 Entry"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("HTTP 404", false, getEngine); + do_check_eq(result.term, "HTTP 404"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "HTTP 404 Entry"); + do_check_eq(result.remote.length, 0); +}); + +add_task(function* http_500() { + yield updateSearchHistory("bump", "HTTP 500 Entry"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("HTTP 500", false, getEngine); + do_check_eq(result.term, "HTTP 500"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "HTTP 500 Entry"); + do_check_eq(result.remote.length, 0); +}); + +add_task(function* unresolvable_server() { + yield updateSearchHistory("bump", "Unresolvable Server Entry"); + + let controller = new SearchSuggestionController(); + let result = yield controller.fetch("Unresolvable Server", false, unresolvableEngine); + do_check_eq(result.term, "Unresolvable Server"); + do_check_eq(result.local.length, 1); + do_check_eq(result.local[0], "Unresolvable Server Entry"); + do_check_eq(result.remote.length, 0); +}); + + +// Exception handling + +add_task(function* missing_pb() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.fetch("No privacy"); + }, /priva/i); +}); + +add_task(function* missing_engine() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.fetch("No engine", false); + }, /engine/i); +}); + +add_task(function* invalid_engine() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.fetch("invalid engine", false, {}); + }, /engine/i); +}); + +add_task(function* no_results_requested() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 0; + controller.maxRemoteResults = 0; + controller.fetch("No results requested", false, getEngine); + }, /result/i); +}); + +add_task(function* minus_one_results_requested() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = -1; + controller.fetch("-1 results requested", false, getEngine); + }, /result/i); +}); + + +// Helpers + +function updateSearchHistory(operation, value) { + let deferred = Promise.defer(); + FormHistory.update({ + op: operation, + fieldname: "searchbar-history", + value: value, + }, + { + handleError: function (error) { + do_throw("Error occurred updating form history: " + error); + deferred.reject(error); + }, + handleCompletion: function (reason) { + if (!reason) + deferred.resolve(); + } + }); + return deferred.promise; +} diff --git a/toolkit/components/search/tests/xpcshell/xpcshell.ini b/toolkit/components/search/tests/xpcshell/xpcshell.ini index e3b907caa5e..cd7cfb4b549 100644 --- a/toolkit/components/search/tests/xpcshell/xpcshell.ini +++ b/toolkit/components/search/tests/xpcshell/xpcshell.ini @@ -7,6 +7,7 @@ support-files = data/engine.src data/engine.xml data/engine2.xml + data/engineMaker.sjs data/engine-rel-searchform.xml data/engine-rel-searchform-post.xml data/engineImages.xml @@ -15,6 +16,7 @@ support-files = data/search-metadata.json data/search.json data/search.sqlite + data/searchSuggestions.sjs data/searchTest.jar [test_nocache.js] @@ -35,6 +37,7 @@ support-files = [test_multipleIcons.js] [test_resultDomain.js] [test_serialize_file.js] +[test_searchSuggest.js] [test_async.js] [test_sync.js] [test_sync_fallback.js]