Bug 1007979 - Refactor nsSearchSuggestions into a reusable JSM. r=adw

Original JSM by mconnor.
This commit is contained in:
Matthew Noorenberghe 2014-07-29 11:28:35 -07:00
parent 4c3a010d92
commit b32d909226
7 changed files with 997 additions and 388 deletions

View File

@ -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;
},
};

View File

@ -11,6 +11,10 @@ EXTRA_COMPONENTS += [
'toolkitsearch.manifest', 'toolkitsearch.manifest',
] ]
EXTRA_JS_MODULES += [
'SearchSuggestionController.jsm',
]
EXTRA_PP_COMPONENTS += [ EXTRA_PP_COMPONENTS += [
'nsSearchService.js', 'nsSearchService.js',
] ]

View File

@ -2,32 +2,24 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this * 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/. */ * 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 BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled";
const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown"; const XPCOM_SHUTDOWN_TOPIC = "xpcom-shutdown";
const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed";
const Cc = Components.classes; const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
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;
Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm"); Cu.import("resource://gre/modules/nsFormAutoCompleteResult.jsm");
Cu.import("resource://gre/modules/Services.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 * SuggestAutoComplete is a base class that implements nsIAutoCompleteSearch
* and can collect results for a given search by using the search URL supplied * and can collect results for a given search by using this._suggestionController.
* by the subclass. We do it this way since the AutoCompleteController in * We do it this way since the AutoCompleteController in Mozilla requires a
* Mozilla requires a unique XPCOM Service for every search provider, even if * unique XPCOM Service for every search provider, even if the logic for two
* the logic for two providers is identical. * providers is identical.
* @constructor * @constructor
*/ */
function SuggestAutoComplete() { function SuggestAutoComplete() {
@ -38,6 +30,7 @@ SuggestAutoComplete.prototype = {
_init: function() { _init: function() {
this._addObservers(); this._addObservers();
this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF); this._suggestEnabled = Services.prefs.getBoolPref(BROWSER_SUGGEST_PREF);
this._suggestionController = new SearchSuggestionController(obj => this.onResultsReturned(obj));
}, },
get _suggestionLabel() { get _suggestionLabel() {
@ -51,59 +44,6 @@ SuggestAutoComplete.prototype = {
*/ */
_suggestEnabled: null, _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 * The object implementing nsIAutoCompleteObserver that we notify when
* we have found results * we have found results
@ -111,81 +51,6 @@ SuggestAutoComplete.prototype = {
*/ */
_listener: null, _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 * Maximum number of history items displayed. This is capped at 7
* because the primary consumer (Firefox search bar) displays 10 rows * because the primary consumer (Firefox search bar) displays 10 rows
@ -195,176 +60,32 @@ SuggestAutoComplete.prototype = {
_historyLimit: 7, _historyLimit: 7,
/** /**
* This clears all the per-request state. * Callback for handling results from SearchSuggestionController.jsm
*/
_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.
*
* @private * @private
*/ */
_okToRequest: function SAC__okToRequest() { onResultsReturned: function(results) {
return Date.now() > this._nextRequestTime; let finalResults = [];
}, let finalComments = [];
/** // If form history has results, add them to the list.
* This checks to see if the new search engine is different let maxHistoryItems = Math.min(results.local.length, this._historyLimit);
* from the previous one, and if so clears any error state that might for (let i = 0; i < maxHistoryItems; ++i) {
* have accumulated for the old engine. finalResults.push(results.local[i]);
* finalComments.push("");
* @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 (this._isBackoffError(status)) { // If there are remote matches, add them.
this._noteServerError(); if (results.remote.length) {
return; // "comments" column values for suggestions starts as empty strings
} let comments = new Array(results.remote.length).fill("", 1);
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)
comments[0] = this._suggestionLabel; comments[0] = this._suggestionLabel;
// now put the history results above the suggestions
// now put the history results above the suggestions finalResults = finalResults.concat(results.remote);
var finalResults = historyResults.concat(results); finalComments = finalComments.concat(comments);
var finalComments = historyComments.concat(comments); }
// Notify the FE of our new results // Notify the FE of our new results
this.onResultsReady(searchString, finalResults, finalComments, this.onResultsReady(results.term, finalResults, finalComments, results.formHistoryResult);
this._formHistoryResult);
// Reset our state for next time.
this._reset();
}, },
/** /**
@ -374,10 +95,9 @@ SuggestAutoComplete.prototype = {
* @param comments an array of metadata corresponding to the results * @param comments an array of metadata corresponding to the results
* @private * @private
*/ */
onResultsReady: function(searchString, results, comments, onResultsReady: function(searchString, results, comments, formHistoryResult) {
formHistoryResult) {
if (this._listener) { if (this._listener) {
var result = new FormAutoCompleteResult( let result = new FormAutoCompleteResult(
searchString, searchString,
Ci.nsIAutoCompleteResult.RESULT_SUCCESS, Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
0, 0,
@ -389,8 +109,7 @@ SuggestAutoComplete.prototype = {
this._listener.onSearchResult(this, result); this._listener.onSearchResult(this, result);
// Null out listener to make sure we don't notify it twice, in case our // Null out listener to make sure we don't notify it twice
// timer callback still hasn't run.
this._listener = null; this._listener = null;
} }
}, },
@ -445,56 +164,10 @@ SuggestAutoComplete.prototype = {
* Actual implementation of search. * Actual implementation of search.
*/ */
_triggerSearch: function(searchString, searchParam, listener, privacyMode) { _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; this._listener = listener;
this._suggestionController.fetch(searchString,
var engine = Services.search.currentEngine; privacyMode,
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);
}
}, },
/** /**
@ -502,10 +175,7 @@ SuggestAutoComplete.prototype = {
* implementation. * implementation.
*/ */
stopSearch: function() { stopSearch: function() {
if (this._request) { this._suggestionController.stop();
this._request.abort();
this._reset();
}
}, },
/** /**
@ -539,31 +209,6 @@ SuggestAutoComplete.prototype = {
Ci.nsIAutoCompleteObserver]) 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 * SearchSuggestAutoComplete is a service implementation that handles suggest
* results specific to web searches. * results specific to web searches.

View File

@ -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 = "<Param name='q' value='{searchTerms}'/>";
} else {
queryString = "?q={searchTerms}";
}
let result = "<?xml version='1.0' encoding='utf-8'?>\
<OpenSearchDescription xmlns='http://a9.com/-/spec/opensearch/1.1/'>\
<ShortName>" + engineData.name + "</ShortName>\
<Description>" + engineData.description + "</Description>\
<InputEncoding>UTF-8</InputEncoding>\
<LongName>" + engineData.name + "</LongName>\
<Url type='application/x-suggestions+json' method='" + engineData.method + "'\
template='" + engineData.baseURL + "searchSuggestions.sjs" + queryString + "'>\
" + params + "\
</Url>\
<Url type='text/html' method='" + engineData.method + "'\
template='" + engineData.baseURL + queryString + "'/>\
</OpenSearchDescription>\
";
response.write(result);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -7,6 +7,7 @@ support-files =
data/engine.src data/engine.src
data/engine.xml data/engine.xml
data/engine2.xml data/engine2.xml
data/engineMaker.sjs
data/engine-rel-searchform.xml data/engine-rel-searchform.xml
data/engine-rel-searchform-post.xml data/engine-rel-searchform-post.xml
data/engineImages.xml data/engineImages.xml
@ -15,6 +16,7 @@ support-files =
data/search-metadata.json data/search-metadata.json
data/search.json data/search.json
data/search.sqlite data/search.sqlite
data/searchSuggestions.sjs
data/searchTest.jar data/searchTest.jar
[test_nocache.js] [test_nocache.js]
@ -35,6 +37,7 @@ support-files =
[test_multipleIcons.js] [test_multipleIcons.js]
[test_resultDomain.js] [test_resultDomain.js]
[test_serialize_file.js] [test_serialize_file.js]
[test_searchSuggest.js]
[test_async.js] [test_async.js]
[test_sync.js] [test_sync.js]
[test_sync_fallback.js] [test_sync_fallback.js]