diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/search | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/search')
183 files changed, 23118 insertions, 0 deletions
diff --git a/toolkit/components/search/.eslintrc.js b/toolkit/components/search/.eslintrc.js new file mode 100644 index 0000000000..1c83c5d5ca --- /dev/null +++ b/toolkit/components/search/.eslintrc.js @@ -0,0 +1,40 @@ +/* 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"; + +module.exports = { + rules: { + "require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: false, + MethodDefinition: false, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "valid-jsdoc": [ + "error", + { + prefer: { + return: "returns", + }, + preferType: { + Boolean: "boolean", + Number: "number", + String: "string", + Object: "object", + bool: "boolean", + }, + requireParamDescription: false, + requireReturn: false, + requireReturnDescription: false, + }, + ], + }, +}; diff --git a/toolkit/components/search/OpenSearchEngine.jsm b/toolkit/components/search/OpenSearchEngine.jsm new file mode 100644 index 0000000000..17eda5d55d --- /dev/null +++ b/toolkit/components/search/OpenSearchEngine.jsm @@ -0,0 +1,623 @@ +/* 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/. */ + +/* eslint no-shadow: error, mozilla/no-aArgs: error */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + EngineURL: "resource://gre/modules/SearchEngine.jsm", + OS: "resource://gre/modules/osfile.jsm", + SearchEngine: "resource://gre/modules/SearchEngine.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(this, { + gChromeReg: ["@mozilla.org/chrome/chrome-registry;1", "nsIChromeRegistry"], + gEnvironment: ["@mozilla.org/process/environment;1", "nsIEnvironment"], +}); + +XPCOMUtils.defineLazyGetter(this, "logConsole", () => { + return console.createInstance({ + prefix: "OpenSearchEngine", + maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +const OPENSEARCH_NS_10 = "http://a9.com/-/spec/opensearch/1.0/"; +const OPENSEARCH_NS_11 = "http://a9.com/-/spec/opensearch/1.1/"; + +// Although the specification at http://opensearch.a9.com/spec/1.1/description/ +// gives the namespace names defined above, many existing OpenSearch engines +// are using the following versions. We therefore allow either. +const OPENSEARCH_NAMESPACES = [ + OPENSEARCH_NS_11, + OPENSEARCH_NS_10, + "http://a9.com/-/spec/opensearchdescription/1.1/", + "http://a9.com/-/spec/opensearchdescription/1.0/", +]; + +const OPENSEARCH_LOCALNAME = "OpenSearchDescription"; + +const MOZSEARCH_NS_10 = "http://www.mozilla.org/2006/browser/search/"; +const MOZSEARCH_LOCALNAME = "SearchPlugin"; + +/** + * Ensures an assertion is met before continuing. Should be used to indicate + * fatal errors. + * @param {*} assertion + * An assertion that must be met + * @param {string} message + * A message to display if the assertion is not met + * @param {number} resultCode + * The NS_ERROR_* value to throw if the assertion is not met + * @throws resultCode + * If the assertion fails. + */ +function ENSURE_WARN(assertion, message, resultCode) { + if (!assertion) { + throw Components.Exception(message, resultCode); + } +} + +/** + * OpenSearchEngine represents an OpenSearch base search engine. + */ +class OpenSearchEngine extends SearchEngine { + // The data describing the engine, in the form of an XML document element. + _data = null; + + /** + * Constructor. + * + * @param {object} options + * The options for this search engine. At least one of + * options.fileURI or options.uri are required. + * @param {nsIFile} [options.fileURI] + * The file URI that points to the search engine data. + * @param {nsIURI|string} [options.uri] + * Represents the location of the search engine data file. + */ + constructor(options = {}) { + let file; + let uri; + let shortName; + if ("fileURI" in options && options.fileURI instanceof Ci.nsIFile) { + file = options.fileURI; + shortName = file.leafName; + } else if ("uri" in options) { + let optionsURI = options.uri; + if (typeof optionsURI == "string") { + optionsURI = SearchUtils.makeURI(optionsURI); + } + // makeURI can return null if the URI is invalid. + if (!optionsURI || !(optionsURI instanceof Ci.nsIURI)) { + throw new Components.Exception( + "options.uri isn't a string nor an nsIURI", + Cr.NS_ERROR_INVALID_ARG + ); + } + switch (optionsURI.scheme) { + case "https": + case "http": + case "ftp": + case "data": + case "file": + case "resource": + case "chrome": + uri = optionsURI; + break; + default: + throw Components.Exception( + "Invalid URI passed to SearchEngine constructor", + Cr.NS_ERROR_INVALID_ARG + ); + } + if ( + gEnvironment.get("XPCSHELL_TEST_PROFILE_DIR") && + uri.scheme == "resource" + ) { + shortName = uri.fileName; + } + } + + if (shortName && shortName.endsWith(".xml")) { + shortName = shortName.slice(0, -4); + } + + super({ + isAppProvided: false, + loadPath: OpenSearchEngine.getAnonymizedLoadPath(shortName, file, uri), + }); + } + + /** + * Retrieves the engine data from a URI. Initializes the engine, flushes to + * disk, and notifies the search service once initialization is complete. + * + * @param {string|nsIURI} uri + * The uri to load the search plugin from. + * @param {function} [callback] + * A callback to receive any details of errors. + */ + _initFromURIAndLoad(uri, callback) { + let loadURI = uri instanceof Ci.nsIURI ? uri : SearchUtils.makeURI(uri); + ENSURE_WARN( + loadURI, + "Must have URI when calling _initFromURIAndLoad!", + Cr.NS_ERROR_UNEXPECTED + ); + + logConsole.debug( + "_initFromURIAndLoad: Downloading engine from:", + loadURI.spec + ); + + var chan = SearchUtils.makeChannel(loadURI); + + if (this._engineToUpdate && chan instanceof Ci.nsIHttpChannel) { + var lastModified = this._engineToUpdate.getAttr("updatelastmodified"); + if (lastModified) { + chan.setRequestHeader("If-Modified-Since", lastModified, false); + } + } + this._uri = loadURI; + + var listener = new SearchUtils.LoadListener( + chan, + this._onLoad.bind(this, callback) + ); + chan.notificationCallbacks = listener; + chan.asyncOpen(listener); + } + + /** + * Retrieves the data from the engine's file asynchronously. + * The document element is placed in the engine's data field. + * + * @param {nsIFile} file + * The file to load the search plugin from. + */ + async _initFromFile(file) { + if (!file || !(await OS.File.exists(file.path))) { + throw Components.Exception( + "File must exist before calling initFromFile!", + Cr.NS_ERROR_UNEXPECTED + ); + } + + let fileURI = Services.io.newFileURI(file); + await this._retrieveSearchXMLData(fileURI.spec); + + // Now that the data is loaded, initialize the engine object + this._initFromData(); + } + + /** + * Retrieves the engine data for a given URI asynchronously. + * + * @param {string} url + * The URL to get engine data from. + * @returns {Promise} + * A promise, resolved successfully if retrieveing data succeeds. + */ + _retrieveSearchXMLData(url) { + return new Promise(resolve => { + let request = new XMLHttpRequest(); + request.overrideMimeType("text/xml"); + request.onload = event => { + let responseXML = event.target.responseXML; + this._data = responseXML.documentElement; + resolve(); + }; + request.onerror = function(event) { + resolve(); + }; + request.open("GET", url, true); + request.send(); + }); + } + + /** + * Handle the successful download of an engine. Initializes the engine and + * triggers parsing of the data. The engine is then flushed to disk. Notifies + * the search service once initialization is complete. + * + * @param {function} callback + * A callback to receive success or failure notifications. May be null. + * @param {array} bytes + * The loaded search engine data. + */ + _onLoad(callback, bytes) { + let onError = errorCode => { + if (this._engineToUpdate) { + logConsole.warn("Failed to update", this._engineToUpdate.name); + } + callback?.(errorCode); + }; + + if (!bytes) { + onError(Ci.nsISearchService.ERROR_DOWNLOAD_FAILURE); + return; + } + + var parser = new DOMParser(); + var doc = parser.parseFromBuffer(bytes, "text/xml"); + this._data = doc.documentElement; + + try { + this._initFromData(); + } catch (ex) { + logConsole.error("_onLoad: Failed to init engine!", ex); + + if (ex.result == Cr.NS_ERROR_FILE_CORRUPTED) { + onError(Ci.nsISearchService.ERROR_ENGINE_CORRUPTED); + } else { + onError(Ci.nsISearchService.ERROR_DOWNLOAD_FAILURE); + } + return; + } + + if (this._engineToUpdate) { + let engineToUpdate = this._engineToUpdate.wrappedJSObject; + + // Preserve metadata and loadPath. + Object.keys(engineToUpdate._metaData).forEach(key => { + this.setAttr(key, engineToUpdate.getAttr(key)); + }); + this._loadPath = engineToUpdate._loadPath; + + // Keep track of the last modified date, so that we can make conditional + // requests for future updates. + this.setAttr("updatelastmodified", new Date().toUTCString()); + + // Set the new engine's icon, if it doesn't yet have one. + if (!this._iconURI && engineToUpdate._iconURI) { + this._iconURI = engineToUpdate._iconURI; + } + } else { + // Check that when adding a new engine (e.g., not updating an + // existing one), a duplicate engine does not already exist. + if (Services.search.getEngineByName(this.name)) { + onError(Ci.nsISearchService.ERROR_DUPLICATE_ENGINE); + logConsole.debug("_onLoad: duplicate engine found, bailing"); + return; + } + + this._loadPath = OpenSearchEngine.getAnonymizedLoadPath( + SearchUtils.sanitizeName(this.name), + null, + this._uri + ); + if (this._extensionID) { + this._loadPath += ":" + this._extensionID; + } + this.setAttr( + "loadPathHash", + SearchUtils.getVerificationHash(this._loadPath) + ); + } + + // Notify the search service of the successful load. It will deal with + // updates by checking this._engineToUpdate. + SearchUtils.notifyAction(this, SearchUtils.MODIFIED_TYPE.LOADED); + + callback?.(); + } + + /** + * Initialize this Engine object from the collected data. + */ + _initFromData() { + ENSURE_WARN( + this._data, + "Can't init an engine with no data!", + Cr.NS_ERROR_UNEXPECTED + ); + + // Ensure we have a supported engine type before attempting to parse it. + let element = this._data; + if ( + (element.localName == MOZSEARCH_LOCALNAME && + element.namespaceURI == MOZSEARCH_NS_10) || + (element.localName == OPENSEARCH_LOCALNAME && + OPENSEARCH_NAMESPACES.includes(element.namespaceURI)) + ) { + logConsole.debug("Initing search plugin from", this._location); + + this._parse(); + } else { + Cu.reportError("Invalid search plugin due to namespace not matching."); + throw Components.Exception( + this._location + " is not a valid search plugin.", + Cr.NS_ERROR_FILE_CORRUPTED + ); + } + // No need to keep a ref to our data (which in some cases can be a document + // element) past this point + this._data = null; + } + + /** + * Extracts data from an OpenSearch URL element and creates an EngineURL + * object which is then added to the engine's list of URLs. + * + * @param {HTMLLinkElement} element + * The OpenSearch URL element. + * @throws NS_ERROR_FAILURE if a URL object could not be created. + * + * @see http://opensearch.a9.com/spec/1.1/querysyntax/#urltag. + * @see EngineURL() + */ + _parseURL(element) { + var type = element.getAttribute("type"); + // According to the spec, method is optional, defaulting to "GET" if not + // specified + var method = element.getAttribute("method") || "GET"; + var template = element.getAttribute("template"); + + let rels = []; + if (element.hasAttribute("rel")) { + rels = element + .getAttribute("rel") + .toLowerCase() + .split(/\s+/); + } + + // Support an alternate suggestion type, see bug 1425827 for details. + if (type == "application/json" && rels.includes("suggestions")) { + type = SearchUtils.URL_TYPE.SUGGEST_JSON; + } + + try { + var url = new EngineURL(type, method, template); + } catch (ex) { + throw Components.Exception( + "_parseURL: failed to add " + template + " as a URL", + Cr.NS_ERROR_FAILURE + ); + } + + if (rels.length) { + url.rels = rels; + } + + for (var i = 0; i < element.children.length; ++i) { + var param = element.children[i]; + if (param.localName == "Param") { + try { + url.addParam(param.getAttribute("name"), param.getAttribute("value")); + } catch (ex) { + // Ignore failure + logConsole.error("_parseURL: Url element has an invalid param"); + } + } + // Note: MozParams are not supported for OpenSearch engines as they + // cannot be app-provided engines. + } + + this._urls.push(url); + } + + /** + * Get the icon from an OpenSearch Image element. + * + * @param {HTMLLinkElement} element + * The OpenSearch URL element. + * @see http://opensearch.a9.com/spec/1.1/description/#image + */ + _parseImage(element) { + let width = parseInt(element.getAttribute("width"), 10); + let height = parseInt(element.getAttribute("height"), 10); + let isPrefered = width == 16 && height == 16; + + if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { + logConsole.warn( + "OpenSearch image element must have positive width and height." + ); + return; + } + + this._setIcon(element.textContent, isPrefered, width, height); + } + + /** + * Extract search engine information from the collected data to initialize + * the engine object. + */ + _parse() { + var doc = this._data; + + for (var i = 0; i < doc.children.length; ++i) { + var child = doc.children[i]; + switch (child.localName) { + case "ShortName": + this._name = child.textContent; + break; + case "Description": + this._description = child.textContent; + break; + case "Url": + try { + this._parseURL(child); + } catch (ex) { + // Parsing of the element failed, just skip it. + logConsole.error("Failed to parse URL child:", ex); + } + break; + case "Image": + this._parseImage(child); + break; + case "InputEncoding": + // If this is not specified we fallback to the SearchEngine constructor + // which currently uses SearchUtils.DEFAULT_QUERY_CHARSET which is + // UTF-8 - the same as for OpenSearch. + this._queryCharset = child.textContent; + break; + + // Non-OpenSearch elements + case "SearchForm": + this._searchForm = child.textContent; + break; + case "UpdateUrl": + this._updateURL = child.textContent; + break; + case "UpdateInterval": + this._updateInterval = parseInt(child.textContent); + break; + case "IconUpdateUrl": + this._iconUpdateURL = child.textContent; + break; + case "ExtensionID": + this._extensionID = child.textContent; + break; + } + } + if (!this.name || !this._urls.length) { + throw Components.Exception( + "_parse: No name, or missing URL!", + Cr.NS_ERROR_FAILURE + ); + } + if (!this.supportsResponseType(SearchUtils.URL_TYPE.SEARCH)) { + throw Components.Exception( + "_parse: No text/html result type!", + Cr.NS_ERROR_FAILURE + ); + } + } + + get _hasUpdates() { + // Whether or not the engine has an update URL + let selfURL = this._getURLOfType(SearchUtils.URL_TYPE.OPENSEARCH, "self"); + return !!(this._updateURL || this._iconUpdateURL || selfURL); + } + + /** + * Gets a directory from the directory service. + * @param {string} key + * The directory service key indicating the directory to get. + * @param {nsIIDRef} iface + * The expected interface type of the directory information. + * @returns {object} + */ + static getDir(key, iface) { + return Services.dirsvc.get(key, iface || Ci.nsIFile); + } + + // This indicates where we found the .xml file to load the engine, + // and attempts to hide user-identifiable data (such as username). + static getAnonymizedLoadPath(shortName, file, uri) { + /* Examples of expected output: + * jar:[app]/omni.ja!browser/engine.xml + * 'browser' here is the name of the chrome package, not a folder. + * [profile]/searchplugins/engine.xml + * [distribution]/searchplugins/common/engine.xml + * [other]/engine.xml + */ + + const NS_XPCOM_CURRENT_PROCESS_DIR = "XCurProcD"; + const NS_APP_USER_PROFILE_50_DIR = "ProfD"; + const XRE_APP_DISTRIBUTION_DIR = "XREAppDist"; + + const knownDirs = { + app: NS_XPCOM_CURRENT_PROCESS_DIR, + profile: NS_APP_USER_PROFILE_50_DIR, + distribution: XRE_APP_DISTRIBUTION_DIR, + }; + + let leafName = shortName; + if (!leafName) { + return "null"; + } + leafName += ".xml"; + + let prefix = "", + suffix = ""; + if (!file) { + if (uri.schemeIs("resource")) { + uri = SearchUtils.makeURI( + Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsISubstitutingProtocolHandler) + .resolveURI(uri) + ); + } + let scheme = uri.scheme; + let packageName = ""; + if (scheme == "chrome") { + packageName = uri.hostPort; + uri = gChromeReg.convertChromeURL(uri); + } + + if (AppConstants.platform == "android") { + // On Android the omni.ja file isn't at the same path as the binary + // used to start the process. We tweak the path here so that the code + // shared with Desktop will correctly identify files from the omni.ja + // file as coming from the [app] folder. + let appPath = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler) + .getSubstitution("android"); + if (appPath) { + appPath = appPath.spec; + let spec = uri.spec; + if (spec.includes(appPath)) { + let appURI = Services.io.newFileURI( + OpenSearchEngine.getDir(knownDirs.app) + ); + uri = Services.io.newURI(spec.replace(appPath, appURI.spec)); + } + } + } + + if (uri instanceof Ci.nsINestedURI) { + prefix = "jar:"; + suffix = "!" + packageName + "/" + leafName; + uri = uri.innermostURI; + } + if (uri instanceof Ci.nsIFileURL) { + file = uri.file; + } else { + let path = "[" + scheme + "]"; + if (/^(?:https?|ftp)$/.test(scheme)) { + path += uri.host; + } + return path + "/" + leafName; + } + } + + let id; + let enginePath = file.path; + + for (let key in knownDirs) { + let path; + try { + path = this.getDir(knownDirs[key]).path; + } catch (e) { + // Getting XRE_APP_DISTRIBUTION_DIR throws during unit tests. + continue; + } + if (enginePath.startsWith(path)) { + id = + "[" + key + "]" + enginePath.slice(path.length).replace(/\\/g, "/"); + break; + } + } + + // If the folder doesn't have a known ancestor, don't record its path to + // avoid leaking user identifiable data. + if (!id) { + id = "[other]/" + file.leafName; + } + + return prefix + id + suffix; + } +} + +var EXPORTED_SYMBOLS = ["OpenSearchEngine"]; diff --git a/toolkit/components/search/SearchEngine.jsm b/toolkit/components/search/SearchEngine.jsm new file mode 100644 index 0000000000..8a7d487741 --- /dev/null +++ b/toolkit/components/search/SearchEngine.jsm @@ -0,0 +1,1707 @@ +/* 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/. */ + +/* eslint no-shadow: error, mozilla/no-aArgs: error */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + ExtensionParent: "resource://gre/modules/ExtensionParent.jsm", + Region: "resource://gre/modules/Region.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +const BinaryInputStream = Components.Constructor( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +XPCOMUtils.defineLazyGetter(this, "logConsole", () => { + return console.createInstance({ + prefix: "SearchEngine", + maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +const USER_DEFINED = "searchTerms"; + +// Supported OpenSearch parameters +// See http://opensearch.a9.com/spec/1.1/querysyntax/#core +const OS_PARAM_INPUT_ENCODING = "inputEncoding"; +const OS_PARAM_LANGUAGE = "language"; +const OS_PARAM_OUTPUT_ENCODING = "outputEncoding"; + +// Default values +const OS_PARAM_LANGUAGE_DEF = "*"; +const OS_PARAM_OUTPUT_ENCODING_DEF = "UTF-8"; + +// "Unsupported" OpenSearch parameters. For example, we don't support +// page-based results, so if the engine requires that we send the "page index" +// parameter, we'll always send "1". +const OS_PARAM_COUNT = "count"; +const OS_PARAM_START_INDEX = "startIndex"; +const OS_PARAM_START_PAGE = "startPage"; + +// Default values +const OS_PARAM_COUNT_DEF = "20"; // 20 results +const OS_PARAM_START_INDEX_DEF = "1"; // start at 1st result +const OS_PARAM_START_PAGE_DEF = "1"; // 1st page + +// A array of arrays containing parameters that we don't fully support, and +// their default values. We will only send values for these parameters if +// required, since our values are just really arbitrary "guesses" that should +// give us the output we want. +var OS_UNSUPPORTED_PARAMS = [ + [OS_PARAM_COUNT, OS_PARAM_COUNT_DEF], + [OS_PARAM_START_INDEX, OS_PARAM_START_INDEX_DEF], + [OS_PARAM_START_PAGE, OS_PARAM_START_PAGE_DEF], +]; + +/** + * Truncates big blobs of (data-)URIs to console-friendly sizes + * @param {string} str + * String to tone down + * @param {number} len + * Maximum length of the string to return. Defaults to the length of a tweet. + * @returns {string} + * The shortend string. + */ +function limitURILength(str, len) { + len = len || 140; + if (str.length > len) { + return str.slice(0, len) + "..."; + } + return str; +} + +/** + * Tries to rescale an icon to a given size. + * + * @param {array} byteArray + * Byte array containing the icon payload. + * @param {string} contentType + * Mime type of the payload. + * @param {number} [size] + * Desired icon size. + * @returns {array} + * An array of two elements - an array of integers and a string for the content + * type. + * @throws if the icon cannot be rescaled or the rescaled icon is too big. + */ +function rescaleIcon(byteArray, contentType, size = 32) { + if (contentType == "image/svg+xml") { + throw new Error("Cannot rescale SVG image"); + } + + let imgTools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools); + let arrayBuffer = new Int8Array(byteArray).buffer; + let container = imgTools.decodeImageFromArrayBuffer(arrayBuffer, contentType); + let stream = imgTools.encodeScaledImage(container, "image/png", size, size); + let streamSize = stream.available(); + if (streamSize > SearchUtils.MAX_ICON_SIZE) { + throw new Error("Icon is too big"); + } + let bis = new BinaryInputStream(stream); + return [bis.readByteArray(streamSize), "image/png"]; +} + +/** + * A simple class to handle caching of preferences that may be read from + * parameters. + */ +const ParamPreferenceCache = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + initCache() { + this.branch = Services.prefs.getDefaultBranch( + SearchUtils.BROWSER_SEARCH_PREF + "param." + ); + this.cache = new Map(); + for (let prefName of this.branch.getChildList("")) { + this.cache.set(prefName, this.branch.getCharPref(prefName, null)); + } + this.branch.addObserver("", this, true); + }, + + observe(subject, topic, data) { + this.cache.set(data, this.branch.getCharPref(data, null)); + }, + + getPref(prefName) { + if (!this.cache) { + this.initCache(); + } + return this.cache.get(prefName); + }, +}; + +/** + * Represents a name/value pair for a parameter + * @see nsISearchEngine::addParam + */ +class QueryParameter { + /** + * @see nsISearchEngine::addParam + * @param {string} name + * @param {string} value + * The value of the parameter. May be an empty string, must not be null or + * undefined. + * @param {string} purpose + * The search purpose for which matches when this parameter should be + * applied, e.g. "searchbar", "contextmenu". + */ + constructor(name, value, purpose) { + if (!name || value == null) { + throw Components.Exception( + "missing name or value for QueryParameter!", + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.name = name; + this._value = value; + this.purpose = purpose; + } + + get value() { + return this._value; + } + + toJSON() { + const result = { + name: this.name, + value: this.value, + }; + if (this.purpose) { + result.purpose = this.purpose; + } + return result; + } +} + +/** + * Represents a special paramater that can be set by preferences. The + * value is read from the 'browser.search.param.*' default preference + * branch. + */ +class QueryPreferenceParameter extends QueryParameter { + /** + * @param {string} name + * The name of the parameter as injected into the query string. + * @param {string} prefName + * The name of the preference to read from the branch. + * @param {string} purpose + * The search purpose for which matches when this parameter should be + * applied, e.g. `searchbar`, `contextmenu`. + */ + constructor(name, prefName, purpose) { + super(name, prefName, purpose); + } + + get value() { + const prefValue = ParamPreferenceCache.getPref(this._value); + return prefValue ? encodeURIComponent(prefValue) : null; + } + + /** + * Converts the object to json. This object is converted with a mozparam flag + * as it gets written to the cache and hence we then know what type it is + * when reading it back. + * + * @returns {object} + */ + toJSON() { + const result = { + condition: "pref", + mozparam: true, + name: this.name, + pref: this._value, + }; + if (this.purpose) { + result.purpose = this.purpose; + } + return result; + } +} + +/** + * Perform OpenSearch parameter substitution on aParamValue. + * @see http://opensearch.a9.com/spec/1.1/querysyntax/#core + * + * @param {string} paramValue + * The OpenSearch search parameters. + * @param {string} searchTerms + * The user-provided search terms. This string will inserted into + * paramValue as the value of the OS_PARAM_USER_DEFINED parameter. + * This value must already be escaped appropriately - it is inserted + * as-is. + * @param {nsISearchEngine} engine + * The engine which owns the string being acted on. + * @returns {string} + * An updated parameter string. + */ +function ParamSubstitution(paramValue, searchTerms, engine) { + const PARAM_REGEXP = /\{((?:\w+:)?\w+)(\??)\}/g; + return paramValue.replace(PARAM_REGEXP, function(match, name, optional) { + // {searchTerms} is by far the most common param so handle it first. + if (name == USER_DEFINED) { + return searchTerms; + } + + // {inputEncoding} is the second most common param. + if (name == OS_PARAM_INPUT_ENCODING) { + return engine.queryCharset; + } + + // moz: parameters are only available for default search engines. + if (name.startsWith("moz:") && engine.isAppProvided) { + // {moz:locale} is common. + if (name == SearchUtils.MOZ_PARAM.LOCALE) { + return Services.locale.requestedLocale; + } + + // {moz:date} + if (name == SearchUtils.MOZ_PARAM.DATE) { + let date = new Date(); + let pad = number => number.toString().padStart(2, "0"); + return ( + String(date.getFullYear()) + + pad(date.getMonth() + 1) + + pad(date.getDate()) + + pad(date.getHours()) + ); + } + + // {moz:distributionID} and {moz:official} seem to have little use. + if (name == SearchUtils.MOZ_PARAM.DIST_ID) { + return Services.prefs.getCharPref( + SearchUtils.BROWSER_SEARCH_PREF + "distributionID", + Services.appinfo.distributionID || "" + ); + } + + if (name == SearchUtils.MOZ_PARAM.OFFICIAL) { + if ( + Services.prefs.getBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "official", + AppConstants.MOZ_OFFICIAL_BRANDING + ) + ) { + return "official"; + } + return "unofficial"; + } + } + + // Handle the less common OpenSearch parameters we're confident about. + if (name == OS_PARAM_LANGUAGE) { + return Services.locale.requestedLocale || OS_PARAM_LANGUAGE_DEF; + } + if (name == OS_PARAM_OUTPUT_ENCODING) { + return OS_PARAM_OUTPUT_ENCODING_DEF; + } + + // At this point, if a parameter is optional, just omit it. + if (optional) { + return ""; + } + + // Replace unsupported parameters that only have hardcoded default values. + for (let param of OS_UNSUPPORTED_PARAMS) { + if (name == param[0]) { + return param[1]; + } + } + + // Don't replace unknown non-optional parameters. + return match; + }); +} + +/** + * EngineURL holds a query URL and all associated parameters. + */ +class EngineURL { + params = []; + rels = []; + + /** + * Constructor + * + * @param {string} mimeType + * The name of the MIME type of the search results returned by this URL. + * @param {string} requestMethod + * The HTTP request method. Must be a case insensitive value of either + * "GET" or "POST". + * @param {string} template + * The URL to which search queries should be sent. For GET requests, + * must contain the string "{searchTerms}", to indicate where the user + * entered search terms should be inserted. + * + * @see http://opensearch.a9.com/spec/1.1/querysyntax/#urltag + * + * @throws NS_ERROR_NOT_IMPLEMENTED if aType is unsupported. + */ + constructor(mimeType, requestMethod, template) { + if (!mimeType || !requestMethod || !template) { + throw Components.Exception( + "missing mimeType, method or template for EngineURL!", + Cr.NS_ERROR_INVALID_ARG + ); + } + + var method = requestMethod.toUpperCase(); + var type = mimeType.toLowerCase(); + + if (method != "GET" && method != "POST") { + throw Components.Exception( + 'method passed to EngineURL must be "GET" or "POST"', + Cr.NS_ERROR_INVALID_ARG + ); + } + + this.type = type; + this.method = method; + this._queryCharset = SearchUtils.DEFAULT_QUERY_CHARSET; + + var templateURI = SearchUtils.makeURI(template); + if (!templateURI) { + throw Components.Exception( + "new EngineURL: template is not a valid URI!", + Cr.NS_ERROR_FAILURE + ); + } + + switch (templateURI.scheme) { + case "http": + case "https": + // Disable these for now, see bug 295018 + // case "file": + // case "resource": + this.template = template; + break; + default: + throw Components.Exception( + "new EngineURL: template uses invalid scheme!", + Cr.NS_ERROR_FAILURE + ); + } + + this.templateHost = templateURI.host; + } + + addParam(name, value, purpose) { + this.params.push(new QueryParameter(name, value, purpose)); + } + + /** + * Adds a MozParam to the parameters list for this URL. For purpose based params + * these are saved as standard parameters, for preference based we save them + * as a special type. + * + * @param {object} param + * @param {string} param.name + * The name of the parameter to add to the url. + * @param {string} [param.condition] + * The type of parameter this is, e.g. "pref" for a preference parameter, + * or "purpose" for a value-based parameter with a specific purpose. The + * default is "purpose". + * @param {string} [param.value] + * The value if it is a "purpose" parameter. + * @param {string} [param.purpose] + * The purpose of the parameter for when it is applied, e.g. for `searchbar` + * searches. + * @param {string} [param.pref] + * The preference name of the parameter, that gets appended to + * `browser.search.param.`. + */ + _addMozParam(param) { + const purpose = param.purpose || undefined; + if (param.condition && param.condition == "pref") { + this.params.push( + new QueryPreferenceParameter(param.name, param.pref, purpose) + ); + } else { + this.addParam(param.name, param.value || undefined, purpose); + } + } + + getSubmission(searchTerms, engine, purpose) { + var url = ParamSubstitution(this.template, searchTerms, engine); + // Default to searchbar if the purpose is not provided + var requestPurpose = purpose || "searchbar"; + + // If a particular purpose isn't defined in the plugin, fallback to 'searchbar'. + if ( + requestPurpose != "searchbar" && + !this.params.some(p => p.purpose && p.purpose == requestPurpose) + ) { + requestPurpose = "searchbar"; + } + + // Create an application/x-www-form-urlencoded representation of our params + // (name=value&name=value&name=value) + let dataArray = []; + for (var i = 0; i < this.params.length; ++i) { + var param = this.params[i]; + + // If this parameter has a purpose, only add it if the purpose matches + if (param.purpose && param.purpose != requestPurpose) { + continue; + } + + let paramValue = param.value; + // Override the parameter value if the engine has a region + // override defined for our current region. + if (engine._regionParams?.[Region.current]) { + let override = engine._regionParams[Region.current].find( + p => p.name == param.name + ); + if (override) { + paramValue = override.value; + } + } + // Preference MozParams might not have a preferenced saved, or a valid value. + if (paramValue != null) { + var value = ParamSubstitution(paramValue, searchTerms, engine); + + dataArray.push(param.name + "=" + value); + } + } + let dataString = dataArray.join("&"); + + var postData = null; + if (this.method == "GET") { + // GET method requests have no post data, and append the encoded + // query string to the url... + if (dataString) { + if (url.includes("?")) { + url = `${url}&${dataString}`; + } else { + url = `${url}?${dataString}`; + } + } + } else if (this.method == "POST") { + // POST method requests must wrap the encoded text in a MIME + // stream and supply that as POSTDATA. + var stringStream = Cc[ + "@mozilla.org/io/string-input-stream;1" + ].createInstance(Ci.nsIStringInputStream); + stringStream.data = dataString; + + postData = Cc["@mozilla.org/network/mime-input-stream;1"].createInstance( + Ci.nsIMIMEInputStream + ); + postData.addHeader("Content-Type", "application/x-www-form-urlencoded"); + postData.setData(stringStream); + } + + return new Submission(Services.io.newURI(url), postData); + } + + _getTermsParameterName() { + let queryParam = this.params.find(p => p.value == "{" + USER_DEFINED + "}"); + return queryParam ? queryParam.name : ""; + } + + _hasRelation(rel) { + return this.rels.some(e => e == rel.toLowerCase()); + } + + _initWithJSON(json) { + if (!json.params) { + return; + } + + this.rels = json.rels; + + for (let i = 0; i < json.params.length; ++i) { + let param = json.params[i]; + // mozparam and purpose are only supported for app-provided engines. + // Since we do not store the details for those engines, we don't want + // to handle it here. + if (!param.mozparam && !param.purpose) { + this.addParam(param.name, param.value); + } + } + } + + /** + * Creates a JavaScript object that represents this URL. + * + * @returns {object} + * An object suitable for serialization as JSON. + */ + toJSON() { + var json = { + params: this.params, + rels: this.rels, + template: this.template, + }; + + if (this.type != SearchUtils.URL_TYPE.SEARCH) { + json.type = this.type; + } + if (this.method != "GET") { + json.method = this.method; + } + + return json; + } +} + +/** + * SearchEngine represents WebExtension based search engines. + */ +class SearchEngine { + QueryInterface = ChromeUtils.generateQI(["nsISearchEngine"]); + // Data set by the user. + _metaData = {}; + // Anonymized path of where we initially loaded the engine from. + // This will stay null for engines installed in the profile before we moved + // to a JSON storage. + _loadPath = null; + // The engine's description + _description = ""; + // Used to store the engine to replace, if we're an update to an existing + // engine. + _engineToUpdate = null; + // Set to true if the engine has a preferred icon (an icon that should not be + // overridden by a non-preferred icon). + _hasPreferredIcon = null; + // The engine's name. + _name = null; + // The name of the charset used to submit the search terms. + _queryCharset = null; + // The engine's raw SearchForm value (URL string pointing to a search form). + __searchForm = null; + // Whether or not to send an attribution request to the server. + _sendAttributionRequest = false; + // The number of days between update checks for new versions + _updateInterval = null; + // The url to check at for a new update + _updateURL = null; + // The url to check for a new icon + _iconUpdateURL = null; + // The extension ID if added by an extension. + _extensionID = null; + // The locale, or "DEFAULT", if required. + _locale = null; + // Whether the engine is provided by the application. + _isAppProvided = false; + // The order hint from the configuration (if any). + _orderHint = null; + // The telemetry id from the configuration (if any). + _telemetryId = null; + // Set to true once the engine has been added to the store, and the initial + // notification sent. This allows to skip sending notifications during + // initialization. + _engineAddedToStore = false; + // The aliases coming from the engine definition (via webextension + // keyword field for example). + _definedAliases = []; + // The urls associated with this engine. + _urls = []; + // The query parameter name of the search url, cached in memory to avoid + // repeated look-ups. + _searchUrlQueryParamName = null; + // The known public suffix of the search url, cached in memory to avoid + // repeated look-ups. + _searchUrlPublicSuffix = null; + + /** + * Constructor. + * + * @param {object} options + * The options for this search engine. + * @param {boolean} options.isAppProvided + * Indicates whether the engine is provided by Firefox, either + * shipped in omni.ja or via Normandy. If it is, it will + * be treated as read-only. + * @param {string} options.loadPath + * The path of the engine was originally loaded from. Should be anonymized. + */ + constructor(options = {}) { + if (!("isAppProvided" in options)) { + throw new Error("isAppProvided missing from options."); + } + if (!("loadPath" in options)) { + throw new Error("loadPath missing from options."); + } + this._isAppProvided = options.isAppProvided; + this._loadPath = options.loadPath; + } + + get _searchForm() { + return this.__searchForm; + } + set _searchForm(value) { + if (/^https?:/i.test(value)) { + this.__searchForm = value; + } else { + logConsole.debug( + "_searchForm: Invalid URL dropped for", + this._name || "the current engine" + ); + } + } + + /** + * Attempts to find an EngineURL object in the set of EngineURLs for + * this Engine that has the given type string. (This corresponds to the + * "type" attribute in the "Url" node in the OpenSearch spec.) + * + * @param {string} type + * The type to match the EngineURL's type attribute. + * @param {string} [rel] + * Only return URLs that with this rel value. + * @returns {EngineURL|null} + * Returns the first matching URL found, null otherwise. + */ + _getURLOfType(type, rel) { + for (let url of this._urls) { + if (url.type == type && (!rel || url._hasRelation(rel))) { + return url; + } + } + + return null; + } + + /** + * Creates a key by serializing an object that contains the icon's width + * and height. + * + * @param {number} width + * Width of the icon. + * @param {number} height + * Height of the icon. + * @returns {string} + * Key string. + */ + _getIconKey(width, height) { + let keyObj = { + width, + height, + }; + + return JSON.stringify(keyObj); + } + + /** + * Add an icon to the icon map used by getIconURIBySize() and getIcons(). + * + * @param {number} width + * Width of the icon. + * @param {number} height + * Height of the icon. + * @param {string} uriSpec + * String with the icon's URI. + */ + _addIconToMap(width, height, uriSpec) { + if (width == 16 && height == 16) { + // The 16x16 icon is stored in _iconURL, we don't need to store it twice. + return; + } + + // Use an object instead of a Map() because it needs to be serializable. + this._iconMapObj = this._iconMapObj || {}; + let key = this._getIconKey(width, height); + this._iconMapObj[key] = uriSpec; + } + + /** + * Sets the .iconURI property of the engine. If both aWidth and aHeight are + * provided an entry will be added to _iconMapObj that will enable accessing + * icon's data through getIcons() and getIconURIBySize() APIs. + * + * @param {string} iconURL + * A URI string pointing to the engine's icon. Must have a http[s], + * ftp, or data scheme. Icons with HTTP[S] or FTP schemes will be + * downloaded and converted to data URIs for storage in the engine + * XML files, if the engine is not built-in. + * @param {boolean} isPreferred + * Whether or not this icon is to be preferred. Preferred icons can + * override non-preferred icons. + * @param {number} [width] + * Width of the icon. + * @param {number} [height] + * Height of the icon. + */ + _setIcon(iconURL, isPreferred, width, height) { + var uri = SearchUtils.makeURI(iconURL); + + // Ignore bad URIs + if (!uri) { + return; + } + + logConsole.debug( + "_setIcon: Setting icon url for", + this.name, + "to", + limitURILength(uri.spec) + ); + // Only accept remote icons from http[s] or ftp + switch (uri.scheme) { + // Fall through to the data case + case "moz-extension": + case "data": + if (!this._hasPreferredIcon || isPreferred) { + this._iconURI = uri; + + this._hasPreferredIcon = isPreferred; + } + + if (width && height) { + this._addIconToMap(width, height, iconURL); + } + break; + case "http": + case "https": + case "ftp": + var chan = SearchUtils.makeChannel(uri); + + let iconLoadCallback = function(byteArray) { + // This callback may run after we've already set a preferred icon, + // so check again. + if (this._hasPreferredIcon && !isPreferred) { + return; + } + + if (!byteArray) { + logConsole.warn("iconLoadCallback: load failed"); + return; + } + + let contentType = chan.contentType; + if (byteArray.length > SearchUtils.MAX_ICON_SIZE) { + try { + logConsole.debug("iconLoadCallback: rescaling icon"); + [byteArray, contentType] = rescaleIcon(byteArray, contentType); + } catch (ex) { + logConsole.error("Unable to set icon for the search engine:", ex); + return; + } + } + + if (!contentType.startsWith("image/")) { + contentType = "image/x-icon"; + } + let dataURL = + "data:" + + contentType + + ";base64," + + btoa(String.fromCharCode.apply(null, byteArray)); + + this._iconURI = SearchUtils.makeURI(dataURL); + + if (width && height) { + this._addIconToMap(width, height, dataURL); + } + + if (this._engineAddedToStore) { + SearchUtils.notifyAction(this, SearchUtils.MODIFIED_TYPE.CHANGED); + } + this._hasPreferredIcon = isPreferred; + }; + + var listener = new SearchUtils.LoadListener( + chan, + // If we're currently acting as an "update engine", then the callback + // should set the icon on the engine we're updating and not us, since + // |this| might be gone by the time the callback runs. + iconLoadCallback.bind(this._engineToUpdate || this) + ); + chan.notificationCallbacks = listener; + chan.asyncOpen(listener); + break; + } + } + + /** + * Initialize an EngineURL object from metadata. + * + * @param {string} type + * The url type. + * @param {object} params + * The URL parameters. + * @param {string|array} [params.getParams] + * Any parameters for a GET method. This is either a query string, or + * an array of objects which have name/value pairs. + * @param {string} [params.method] + * The type of method, defaults to GET. + * @param {string} [params.mozParams] + * Any special Mozilla Parameters. + * @param {string|array} [params.postParams] + * Any parameters for a POST method. This is either a query string, or + * an array of objects which have name/value pairs. + * @param {string} params.template + * The url template. + * @returns {EngineURL} + * The newly created EngineURL. + */ + _getEngineURLFromMetaData(type, params) { + let url = new EngineURL(type, params.method || "GET", params.template); + + // Do the MozParams first, so that we are more likely to get the query + // on the end of the URL, rather than the MozParams (xref bug 1484232). + if (params.mozParams) { + for (let p of params.mozParams) { + if ((p.condition || p.purpose) && !this.isAppProvided) { + continue; + } + url._addMozParam(p); + } + } + if (params.postParams) { + if (Array.isArray(params.postParams)) { + for (let { name, value } of params.postParams) { + url.addParam(name, value); + } + } else { + for (let [name, value] of new URLSearchParams(params.postParams)) { + url.addParam(name, value); + } + } + } + + if (params.getParams) { + if (Array.isArray(params.getParams)) { + for (let { name, value } of params.getParams) { + url.addParam(name, value); + } + } else { + for (let [name, value] of new URLSearchParams(params.getParams)) { + url.addParam(name, value); + } + } + } + + return url; + } + + /** + * Initialize this Engine object from a WebExtension style manifest. + * + * @param {string} extensionID + * The WebExtension ID. For Policy engines, this is currently "set-via-policy". + * @param {string} extensionBaseURI + * The Base URI of the WebExtension. + * @param {object} manifest + * An object representing the WebExtensions' manifest. + * @param {string} locale + * The locale that is being used for the WebExtension. + * @param {object} [configuration] + * The search engine configuration for application provided engines, that + * may be overriding some of the WebExtension's settings. + */ + _initFromManifest( + extensionID, + extensionBaseURI, + manifest, + locale, + configuration = {} + ) { + let { IconDetails } = ExtensionParent; + + let searchProvider = manifest.chrome_settings_overrides.search_provider; + + let iconURL = manifest.iconURL || searchProvider.favicon_url; + + // General set of icons for an engine. + let icons = manifest.icons; + let iconList = []; + if (icons) { + iconList = Object.entries(icons).map(icon => { + return { + width: icon[0], + height: icon[0], + url: extensionBaseURI.resolve(icon[1]), + }; + }); + } + + if (!iconURL) { + iconURL = + icons && + extensionBaseURI.resolve(IconDetails.getPreferredIcon(icons).icon); + } + + // We only set _telemetryId for app-provided engines. See also telemetryId + // getter. + if (this._isAppProvided) { + if (configuration.telemetryId) { + this._telemetryId = configuration.telemetryId; + } else { + let telemetryId = extensionID.split("@")[0]; + if (locale != SearchUtils.DEFAULT_TAG) { + telemetryId += "-" + locale; + } + this._telemetryId = telemetryId; + } + } + + this._extensionID = extensionID; + this._locale = locale; + this._orderHint = configuration.orderHint; + this._name = searchProvider.name.trim(); + this._regionParams = configuration.regionParams; + this._sendAttributionRequest = + configuration.sendAttributionRequest ?? false; + + this._definedAliases = []; + if (Array.isArray(searchProvider.keyword)) { + this._definedAliases = searchProvider.keyword.map(k => k.trim()); + } else if (searchProvider.keyword?.trim()) { + this._definedAliases = [searchProvider.keyword?.trim()]; + } + + this._description = manifest.description; + if (iconURL) { + this._setIcon(iconURL, true); + } + // Other sizes + if (iconList) { + for (let icon of iconList) { + this._addIconToMap(icon.size, icon.size, icon.url); + } + } + this._setUrls(searchProvider, configuration); + } + + /** + * This sets the urls for the search engine based on the supplied parameters. + * If you add anything here, please consider if it needs to be handled in the + * overrideWithExtension / removeExtensionOverride functions as well. + * + * @param {object} searchProvider + * The WebExtension search provider object extracted from the manifest. + * @param {object} [configuration] + * The search engine configuration for application provided engines, that + * may be overriding some of the WebExtension's settings. + */ + _setUrls(searchProvider, configuration = {}) { + // Filter out any untranslated parameters, the extension has to list all + // possible mozParams for each engine where a 'locale' may only provide + // actual values for some (or none). + if (searchProvider.params) { + searchProvider.params = searchProvider.params.filter(param => { + return !(param.value && param.value.startsWith("__MSG_")); + }); + } + + let postParams = + configuration.params?.searchUrlPostParams || + searchProvider.search_url_post_params || + ""; + let url = this._getEngineURLFromMetaData(SearchUtils.URL_TYPE.SEARCH, { + method: (postParams && "POST") || "GET", + // AddonManager will sometimes encode the URL via `new URL()`. We want + // to ensure we're always dealing with decoded urls. + template: decodeURI(searchProvider.search_url), + getParams: + configuration.params?.searchUrlGetParams || + searchProvider.search_url_get_params || + "", + postParams, + mozParams: configuration.extraParams || searchProvider.params || [], + }); + + this._urls.push(url); + + if (searchProvider.suggest_url) { + let suggestPostParams = + configuration.params?.suggestUrlPostParams || + searchProvider.suggest_url_post_params || + ""; + url = this._getEngineURLFromMetaData(SearchUtils.URL_TYPE.SUGGEST_JSON, { + method: (suggestPostParams && "POST") || "GET", + // suggest_url doesn't currently get encoded. + template: searchProvider.suggest_url, + getParams: + configuration.params?.suggestUrlGetParams || + searchProvider.suggest_url_get_params || + "", + postParams: suggestPostParams, + }); + + this._urls.push(url); + } + + if (searchProvider.encoding) { + this._queryCharset = searchProvider.encoding; + } + this.__searchForm = searchProvider.search_form; + } + + checkSearchUrlMatchesManifest(searchProvider) { + let existingUrl = this._getURLOfType(SearchUtils.URL_TYPE.SEARCH); + + let newUrl = this._getEngineURLFromMetaData(SearchUtils.URL_TYPE.SEARCH, { + method: (searchProvider.search_url_post_params && "POST") || "GET", + // AddonManager will sometimes encode the URL via `new URL()`. We want + // to ensure we're always dealing with decoded urls. + template: decodeURI(searchProvider.search_url), + getParams: searchProvider.search_url_get_params || "", + postParams: searchProvider.search_url_post_params || "", + }); + + let existingSubmission = existingUrl.getSubmission("", this); + let newSubmission = newUrl.getSubmission("", this); + + return ( + existingSubmission.uri.equals(newSubmission.uri) && + existingSubmission.postData == newSubmission.postData + ); + } + + /** + * Update this engine based on new manifest, used during + * webextension upgrades. + * + * @param {string} extensionID + * The WebExtension ID. For Policy engines, this is currently "set-via-policy". + * @param {string} extensionBaseURI + * The Base URI of the WebExtension. + * @param {object} manifest + * An object representing the WebExtensions' manifest. + * @param {string} locale + * The locale that is being used for the WebExtension. + * @param {object} [configuration] + * The search engine configuration for application provided engines, that + * may be overriding some of the WebExtension's settings. + */ + _updateFromManifest( + extensionID, + extensionBaseURI, + manifest, + locale, + configuration = {} + ) { + this._urls = []; + this._iconMapObj = null; + this._initFromManifest( + extensionID, + extensionBaseURI, + manifest, + locale, + configuration + ); + SearchUtils.notifyAction(this, SearchUtils.MODIFIED_TYPE.CHANGED); + } + + /** + * Overrides the urls/parameters with those of the provided extension. + * The parameters are not saved to the search settings - the code handling + * the extension should set these on every restart, this avoids potential + * third party modifications and means that we can verify the WebExtension is + * still in the allow list. + * + * @param {string} extensionID + * The WebExtension ID. For Policy engines, this is currently "set-via-policy". + * @param {object} manifest + * An object representing the WebExtensions' manifest. + */ + overrideWithExtension(extensionID, manifest) { + this._overriddenData = { + urls: this._urls, + queryCharset: this._queryCharset, + searchForm: this.__searchForm, + }; + this._urls = []; + this.setAttr("overriddenBy", extensionID); + this._setUrls(manifest.chrome_settings_overrides.search_provider); + SearchUtils.notifyAction(this, SearchUtils.MODIFIED_TYPE.CHANGED); + } + + /** + * Resets the overrides for the engine if it has been overridden. + */ + removeExtensionOverride() { + if (this.getAttr("overriddenBy")) { + this._urls = this._overriddenData.urls; + this._queryCharset = this._overriddenData.queryCharset; + this.__searchForm = this._overriddenData.searchForm; + delete this._overriddenData; + this.clearAttr("overriddenBy"); + SearchUtils.notifyAction(this, SearchUtils.MODIFIED_TYPE.CHANGED); + } + } + + /** + * Init from a JSON record. + * + * @param {object} json + * The json record to use. + */ + _initWithJSON(json) { + this._name = json._name; + this._description = json.description; + this._hasPreferredIcon = json._hasPreferredIcon == undefined; + this._queryCharset = json.queryCharset || SearchUtils.DEFAULT_QUERY_CHARSET; + this.__searchForm = json.__searchForm; + this._updateInterval = json._updateInterval || null; + this._updateURL = json._updateURL || null; + this._iconUpdateURL = json._iconUpdateURL || null; + this._iconURI = SearchUtils.makeURI(json._iconURL); + this._iconMapObj = json._iconMapObj; + this._metaData = json._metaData || {}; + this._orderHint = json._orderHint || null; + this._definedAliases = json._definedAliases || []; + // These changed keys in Firefox 80, maintain the old keys + // for backwards compatibility. + if (json._definedAlias) { + this._definedAliases.push(json._definedAlias); + } + this._filePath = json.filePath || json._filePath || null; + this._extensionID = json.extensionID || json._extensionID || null; + this._locale = json.extensionLocale || json._locale || null; + + for (let i = 0; i < json._urls.length; ++i) { + let url = json._urls[i]; + let engineURL = new EngineURL( + url.type || SearchUtils.URL_TYPE.SEARCH, + url.method || "GET", + url.template + ); + engineURL._initWithJSON(url); + this._urls.push(engineURL); + } + } + + /** + * Creates a JavaScript object that represents this engine. + * @returns {object} + * An object suitable for serialization as JSON. + */ + toJSON() { + // For built-in engines we don't want to store all their data in the settings + // file so just store the relevant metadata. + if (this._isAppProvided) { + return { + _name: this.name, + _isAppProvided: true, + _metaData: this._metaData, + }; + } + + const fieldsToCopy = [ + "_name", + "_loadPath", + "description", + "__searchForm", + "_iconURL", + "_iconMapObj", + "_metaData", + "_urls", + "_isAppProvided", + "_orderHint", + "_telemetryId", + "_updateInterval", + "_updateURL", + "_iconUpdateURL", + "_filePath", + "_extensionID", + "_locale", + "_definedAliases", + ]; + + let json = {}; + for (const field of fieldsToCopy) { + if (field in this) { + json[field] = this[field]; + } + } + + if (!this._hasPreferredIcon) { + json._hasPreferredIcon = this._hasPreferredIcon; + } + if (this.queryCharset != SearchUtils.DEFAULT_QUERY_CHARSET) { + json.queryCharset = this.queryCharset; + } + + return json; + } + + setAttr(name, val) { + this._metaData[name] = val; + } + + getAttr(name) { + return this._metaData[name] || undefined; + } + + clearAttr(name) { + delete this._metaData[name]; + } + + // nsISearchEngine + + /** + * Get the user-defined alias. + * + * @returns {string} + */ + get alias() { + return this.getAttr("alias"); + } + + /** + * Set the user-defined alias. + * + * @param {string} val + */ + set alias(val) { + var value = val ? val.trim() : null; + this.setAttr("alias", value); + SearchUtils.notifyAction(this, SearchUtils.MODIFIED_TYPE.CHANGED); + } + + /** + * Returns a list of aliases, including a user defined alias and + * a list defined by webextension keywords. + * + * @returns {Array} + */ + get aliases() { + return [ + ...(this.getAttr("alias") ? [this.getAttr("alias")] : []), + ...this._definedAliases, + ]; + } + + /** + * Returns the appropriate identifier to use for telemetry. It is based on + * the following order: + * + * - telemetryId: The telemetry id from the configuration, or derived from + * the WebExtension name. + * - other-<name>: The engine name prefixed by `other-` for non-app-provided + * engines. + * + * @returns {string} + */ + get telemetryId() { + let telemetryId = this._telemetryId || `other-${this.name}`; + if (this.getAttr("overriddenBy")) { + return telemetryId + "-addon"; + } + return telemetryId; + } + + /** + * Return the built-in identifier of app-provided engines. + * + * @returns {string|null} + * Returns a valid if this is a built-in engine, null otherwise. + */ + get identifier() { + // No identifier if If the engine isn't app-provided + return this.isAppProvided ? this._telemetryId : null; + } + + get description() { + return this._description; + } + + get hidden() { + return this.getAttr("hidden") || false; + } + set hidden(val) { + var value = !!val; + if (value != this.hidden) { + this.setAttr("hidden", value); + SearchUtils.notifyAction(this, SearchUtils.MODIFIED_TYPE.CHANGED); + } + } + + get iconURI() { + if (this._iconURI) { + return this._iconURI; + } + return null; + } + + get _iconURL() { + if (!this._iconURI) { + return ""; + } + return this._iconURI.spec; + } + + // Where the engine is being loaded from: will return the URI's spec if the + // engine is being downloaded and does not yet have a file. This is only used + // for logging and error messages. + get _location() { + if (this._uri) { + return this._uri.spec; + } + + return this._loadPath; + } + + get isAppProvided() { + return !!(this._extensionID && this._isAppProvided); + } + + get _hasUpdates() { + return false; + } + + get name() { + return this._name; + } + + get searchForm() { + return this._getSearchFormWithPurpose(); + } + + get sendAttributionRequest() { + return this._sendAttributionRequest; + } + + _getSearchFormWithPurpose(purpose) { + // First look for a <Url rel="searchform"> + var searchFormURL = this._getURLOfType( + SearchUtils.URL_TYPE.SEARCH, + "searchform" + ); + if (searchFormURL) { + let submission = searchFormURL.getSubmission("", this, purpose); + + // If the rel=searchform URL is not type="get" (i.e. has postData), + // ignore it, since we can only return a URL. + if (!submission.postData) { + return submission.uri.spec; + } + } + + if (!this._searchForm) { + // No SearchForm specified in the engine definition file, use the prePath + // (e.g. https://foo.com for https://foo.com/search.php?q=bar). + var htmlUrl = this._getURLOfType(SearchUtils.URL_TYPE.SEARCH); + if (!htmlUrl) { + throw Components.Exception( + "Engine has no HTML URL!", + Cr.NS_ERROR_UNEXPECTED + ); + } + this._searchForm = SearchUtils.makeURI(htmlUrl.template).prePath; + } + + return ParamSubstitution(this._searchForm, "", this); + } + + get queryCharset() { + return this._queryCharset || SearchUtils.DEFAULT_QUERY_CHARSET; + } + + get _defaultMobileResponseType() { + let type = SearchUtils.URL_TYPE.SEARCH; + + let isTablet = Services.sysinfo.get("tablet"); + if ( + isTablet && + this.supportsResponseType("application/x-moz-tabletsearch") + ) { + // Check for a tablet-specific search URL override + type = "application/x-moz-tabletsearch"; + } else if ( + !isTablet && + this.supportsResponseType("application/x-moz-phonesearch") + ) { + // Check for a phone-specific search URL override + type = "application/x-moz-phonesearch"; + } + + Object.defineProperty(this, "_defaultMobileResponseType", { + value: type, + configurable: true, + }); + + return type; + } + + // from nsISearchEngine + getSubmission(data, responseType, purpose) { + if (!responseType) { + responseType = + AppConstants.platform == "android" + ? this._defaultMobileResponseType + : SearchUtils.URL_TYPE.SEARCH; + } + + var url = this._getURLOfType(responseType); + + if (!url) { + return null; + } + + if (!data) { + // Return a dummy submission object with our searchForm attribute + return new Submission( + SearchUtils.makeURI(this._getSearchFormWithPurpose(purpose)) + ); + } + + var submissionData = ""; + try { + submissionData = Services.textToSubURI.ConvertAndEscape( + this.queryCharset, + data + ); + } catch (ex) { + logConsole.warn("getSubmission: Falling back to default queryCharset!"); + submissionData = Services.textToSubURI.ConvertAndEscape( + SearchUtils.DEFAULT_QUERY_CHARSET, + data + ); + } + return url.getSubmission(submissionData, this, purpose); + } + + get searchUrlQueryParamName() { + if (this._searchUrlQueryParamName != null) { + return this._searchUrlQueryParamName; + } + + let submission = this.getSubmission( + "{searchTerms}", + SearchUtils.URL_TYPE.SEARCH + ); + + if (submission.postData) { + Cu.reportError("searchUrlQueryParamName can't handle POST urls."); + return (this._searchUrlQueryParamName = ""); + } + + let queryParams = new URLSearchParams(submission.uri.query); + let searchUrlQueryParamName = ""; + for (let [key, value] of queryParams) { + if (value == "{searchTerms}") { + searchUrlQueryParamName = key; + } + } + + return (this._searchUrlQueryParamName = searchUrlQueryParamName); + } + + get searchUrlPublicSuffix() { + if (this._searchUrlPublicSuffix != null) { + return this._searchUrlPublicSuffix; + } + let submission = this.getSubmission( + "{searchTerms}", + SearchUtils.URL_TYPE.SEARCH + ); + let searchURLPublicSuffix = Services.eTLD.getKnownPublicSuffix( + submission.uri + ); + return (this._searchUrlPublicSuffix = searchURLPublicSuffix); + } + + // from nsISearchEngine + supportsResponseType(type) { + return this._getURLOfType(type) != null; + } + + // from nsISearchEngine + getResultDomain(responseType) { + if (!responseType) { + responseType = + AppConstants.platform == "android" + ? this._defaultMobileResponseType + : SearchUtils.URL_TYPE.SEARCH; + } + + let url = this._getURLOfType(responseType); + if (url) { + return url.templateHost; + } + return ""; + } + + /** + * @returns {object} + * URL parsing properties used by _buildParseSubmissionMap. + */ + getURLParsingInfo() { + let responseType = + AppConstants.platform == "android" + ? this._defaultMobileResponseType + : SearchUtils.URL_TYPE.SEARCH; + + let url = this._getURLOfType(responseType); + if (!url || url.method != "GET") { + return null; + } + + let termsParameterName = url._getTermsParameterName(); + if (!termsParameterName) { + return null; + } + + let templateUrl = Services.io + .newURI(url.template) + .QueryInterface(Ci.nsIURL); + return { + mainDomain: templateUrl.host, + path: templateUrl.filePath.toLowerCase(), + termsParameterName, + }; + } + + get wrappedJSObject() { + return this; + } + + /** + * Returns a string with the URL to an engine's icon matching both width and + * height. Returns null if icon with specified dimensions is not found. + * + * @param {number} width + * Width of the requested icon. + * @param {number} height + * Height of the requested icon. + * @returns {string|null} + */ + getIconURLBySize(width, height) { + if (width == 16 && height == 16) { + return this._iconURL; + } + + if (!this._iconMapObj) { + return null; + } + + let key = this._getIconKey(width, height); + if (key in this._iconMapObj) { + return this._iconMapObj[key]; + } + return null; + } + + /** + * Gets an array of all available icons. Each entry is an object with + * width, height and url properties. width and height are numeric and + * represent the icon's dimensions. url is a string with the URL for + * the icon. + * + * @returns {Array<object>} + * An array of objects with width/height/url parameters. + */ + getIcons() { + let result = []; + if (this._iconURL) { + result.push({ width: 16, height: 16, url: this._iconURL }); + } + + if (!this._iconMapObj) { + return result; + } + + for (let key of Object.keys(this._iconMapObj)) { + let iconSize = JSON.parse(key); + result.push({ + width: iconSize.width, + height: iconSize.height, + url: this._iconMapObj[key], + }); + } + + return result; + } + + /** + * Opens a speculative connection to the engine's search URI + * (and suggest URI, if different) to reduce request latency + * + * @param {object} options + * @param {DOMWindow} options.window + * The content window for the window performing the search. + * @param {object} options.originAttributes + * The originAttributes for performing the search + * @throws NS_ERROR_INVALID_ARG if options is omitted or lacks required + * elemeents + */ + speculativeConnect(options) { + if (!options || !options.window) { + Cu.reportError( + "invalid options arg passed to nsISearchEngine.speculativeConnect" + ); + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + let connector = Services.io.QueryInterface(Ci.nsISpeculativeConnect); + + let searchURI = this.getSubmission("dummy").uri; + + let callbacks = options.window.docShell.QueryInterface(Ci.nsILoadContext); + + // Using the content principal which is constructed by the search URI + // and given originAttributes. If originAttributes are not given, we + // fallback to use the docShell's originAttributes. + let attrs = options.originAttributes; + + if (!attrs) { + attrs = options.window.docShell.getOriginAttributes(); + } + + let principal = Services.scriptSecurityManager.createContentPrincipal( + searchURI, + attrs + ); + + try { + connector.speculativeConnect(searchURI, principal, callbacks); + } catch (e) { + // Can't setup speculative connection for this url, just ignore it. + Cu.reportError(e); + } + + if (this.supportsResponseType(SearchUtils.URL_TYPE.SUGGEST_JSON)) { + let suggestURI = this.getSubmission( + "dummy", + SearchUtils.URL_TYPE.SUGGEST_JSON + ).uri; + if (suggestURI.prePath != searchURI.prePath) { + try { + connector.speculativeConnect(suggestURI, principal, callbacks); + } catch (e) { + // Can't setup speculative connection for this url, just ignore it. + Cu.reportError(e); + } + } + } + } +} + +/** + * Implements nsISearchSubmission. + */ +class Submission { + QueryInterface = ChromeUtils.generateQI(["nsISearchSubmission"]); + + constructor(uri, postData = null) { + this._uri = uri; + this._postData = postData; + } + + get uri() { + return this._uri; + } + get postData() { + return this._postData; + } +} + +var EXPORTED_SYMBOLS = ["EngineURL", "SearchEngine"]; diff --git a/toolkit/components/search/SearchEngineSelector.jsm b/toolkit/components/search/SearchEngineSelector.jsm new file mode 100644 index 0000000000..9f61477182 --- /dev/null +++ b/toolkit/components/search/SearchEngineSelector.jsm @@ -0,0 +1,429 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["SearchEngineSelector"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + RemoteSettings: "resource://services-settings/remote-settings.js", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +const USER_LOCALE = "$USER_LOCALE"; + +XPCOMUtils.defineLazyGetter(this, "logConsole", () => { + return console.createInstance({ + prefix: "SearchEngineSelector", + maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +function getAppInfo(key) { + let value = null; + try { + // Services.appinfo is often null in tests. + value = Services.appinfo[key].toLowerCase(); + } catch (e) {} + return value; +} + +function hasAppKey(config, key) { + return "application" in config && key in config.application; +} + +function sectionExcludes(config, key, value) { + return hasAppKey(config, key) && !config.application[key].includes(value); +} + +function sectionIncludes(config, key, value) { + return hasAppKey(config, key) && config.application[key].includes(value); +} + +function isDistroExcluded(config, key, distroID) { + // Should be excluded when: + // - There's a distroID and that is not in the non-empty distroID list. + // - There's no distroID and the distroID list is not empty. + const appKey = hasAppKey(config, key); + if (!appKey) { + return false; + } + const distroList = config.application[key]; + if (distroID) { + return distroList.length && !distroList.includes(distroID); + } + return !!distroList.length; +} + +function belowMinVersion(config, version) { + return ( + hasAppKey(config, "minVersion") && + Services.vc.compare(version, config.application.minVersion) < 0 + ); +} + +function aboveMaxVersion(config, version) { + return ( + hasAppKey(config, "maxVersion") && + Services.vc.compare(version, config.application.maxVersion) > 0 + ); +} + +/** + * SearchEngineSelector parses the JSON configuration for + * search engines and returns the applicable engines depending + * on their region + locale. + */ +class SearchEngineSelector { + /** + * @param {function} listener + * A listener for configuration update changes. + */ + constructor(listener) { + this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]); + this._remoteConfig = RemoteSettings(SearchUtils.SETTINGS_KEY); + this._listenerAdded = false; + this._onConfigurationUpdated = this._onConfigurationUpdated.bind(this); + this._changeListener = listener; + } + + /** + * Handles getting the configuration from remote settings. + */ + async getEngineConfiguration() { + if (this._getConfigurationPromise) { + return this._getConfigurationPromise; + } + + this._configuration = await (this._getConfigurationPromise = this._getConfiguration()); + delete this._getConfigurationPromise; + + if (!this._configuration?.length) { + throw Components.Exception( + "Failed to get engine data from Remote Settings", + Cr.NS_ERROR_UNEXPECTED + ); + } + + if (!this._listenerAdded) { + this._remoteConfig.on("sync", this._onConfigurationUpdated); + this._listenerAdded = true; + } + + return this._configuration; + } + + /** + * Obtains the configuration from remote settings. This includes + * verifying the signature of the record within the database. + * + * If the signature in the database is invalid, the database will be wiped + * and the stored dump will be used, until the settings next update. + * + * Note that this may cause a network check of the certificate, but that + * should generally be quick. + * + * @param {boolean} [firstTime] + * Internal boolean to indicate if this is the first time check or not. + * @returns {array} + * An array of objects in the database, or an empty array if none + * could be obtained. + */ + async _getConfiguration(firstTime = true) { + let result = []; + let failed = false; + try { + result = await this._remoteConfig.get({ order: "id" }); + } catch (ex) { + logConsole.error(ex); + failed = true; + } + if (!result.length) { + logConsole.error("Received empty search configuration!"); + failed = true; + } + // If we failed, or the result is empty, try loading from the local dump. + if (firstTime && failed) { + await this._remoteConfig.db.clear(); + // Now call this again. + return this._getConfiguration(false); + } + return result; + } + + /** + * Handles updating of the configuration. Note that the search service is + * only updated after a period where the user is observed to be idle. + */ + _onConfigurationUpdated({ data: { current } }) { + this._configuration = current; + logConsole.debug("Search configuration updated remotely"); + if (this._changeListener) { + this._changeListener(); + } + } + + /** + * @param {object} options + * @param {string} options.locale + * Users locale. + * @param {string} options.region + * Users region. + * @param {string} [options.channel] + * The update channel the application is running on. + * @param {string} [options.distroID] + * The distribution ID of the application. + * @param {string} [options.experiment] + * Any associated experiment id. + * @returns {object} + * An object with "engines" field, a sorted list of engines and + * optionally "privateDefault" which is an object containing the engine + * details for the engine which should be the default in Private Browsing mode. + */ + async fetchEngineConfiguration({ + locale, + region, + channel = "default", + distroID, + experiment, + }) { + if (!this._configuration) { + await this.getEngineConfiguration(); + } + let name = getAppInfo("name"); + let version = getAppInfo("version"); + logConsole.debug( + `fetchEngineConfiguration ${locale}:${region}:${channel}:${distroID}:${experiment}:${name}:${version}` + ); + let engines = []; + const lcLocale = locale.toLowerCase(); + const lcRegion = region.toLowerCase(); + for (let config of this._configuration) { + const appliesTo = config.appliesTo || []; + const applies = appliesTo.filter(section => { + if ("experiment" in section) { + if (experiment != section.experiment) { + return false; + } + if (section.override) { + return true; + } + } + + const distroExcluded = + (distroID && + sectionIncludes(section, "excludedDistributions", distroID)) || + isDistroExcluded(section, "distributions", distroID); + + if (distroID && !distroExcluded && section.override) { + return true; + } + + if ( + sectionExcludes(section, "channel", channel) || + sectionExcludes(section, "name", name) || + distroExcluded || + belowMinVersion(section, version) || + aboveMaxVersion(section, version) + ) { + return false; + } + let included = + "included" in section && + this._isInSection(lcRegion, lcLocale, section.included); + let excluded = + "excluded" in section && + this._isInSection(lcRegion, lcLocale, section.excluded); + return included && !excluded; + }); + + let baseConfig = this._copyObject({}, config); + + // Don't include any engines if every section is an override + // entry, these are only supposed to override otherwise + // included engine configurations. + let allOverrides = applies.every(e => "override" in e && e.override); + // Loop through all the appliedTo sections that apply to + // this configuration. + if (applies.length && !allOverrides) { + for (let section of applies) { + this._copyObject(baseConfig, section); + } + + if ( + "webExtension" in baseConfig && + "locales" in baseConfig.webExtension + ) { + for (const webExtensionLocale of baseConfig.webExtension.locales) { + const engine = { ...baseConfig }; + engine.webExtension = { ...baseConfig.webExtension }; + delete engine.webExtension.locales; + engine.webExtension.locale = + webExtensionLocale == USER_LOCALE ? locale : webExtensionLocale; + engines.push(engine); + } + } else { + const engine = { ...baseConfig }; + (engine.webExtension = engine.webExtension || {}).locale = + SearchUtils.DEFAULT_TAG; + engines.push(engine); + } + } + } + + let defaultEngine; + let privateEngine; + + function shouldPrefer(setting, hasCurrentDefault, currentDefaultSetting) { + if ( + setting == "yes" && + (!hasCurrentDefault || currentDefaultSetting == "yes-if-no-other") + ) { + return true; + } + return setting == "yes-if-no-other" && !hasCurrentDefault; + } + + for (const engine of engines) { + if ( + "default" in engine && + shouldPrefer( + engine.default, + !!defaultEngine, + defaultEngine && defaultEngine.default + ) + ) { + defaultEngine = engine; + } + if ( + "defaultPrivate" in engine && + shouldPrefer( + engine.defaultPrivate, + !!privateEngine, + privateEngine && privateEngine.defaultPrivate + ) + ) { + privateEngine = engine; + } + } + + engines.sort(this._sort.bind(this, defaultEngine, privateEngine)); + + let result = { engines }; + + if (privateEngine) { + result.privateDefault = privateEngine; + } + + if (SearchUtils.loggingEnabled) { + logConsole.debug( + "fetchEngineConfiguration: " + + result.engines.map(e => e.webExtension.id) + ); + } + return result; + } + + _sort(defaultEngine, privateEngine, a, b) { + return ( + this._sortIndex(b, defaultEngine, privateEngine) - + this._sortIndex(a, defaultEngine, privateEngine) + ); + } + + /** + * Create an index order to ensure default (and backup default) + * engines are ordered correctly. + * @param {object} obj + * Object representing the engine configation. + * @param {object} defaultEngine + * The default engine, for comparison to obj. + * @param {object} privateEngine + * The private engine, for comparison to obj. + * @returns {integer} + * Number indicating how this engine should be sorted. + */ + _sortIndex(obj, defaultEngine, privateEngine) { + if (obj == defaultEngine) { + return Number.MAX_SAFE_INTEGER; + } + if (obj == privateEngine) { + return Number.MAX_SAFE_INTEGER - 1; + } + return obj.orderHint || 0; + } + + /** + * Is the engine marked to be the default search engine. + * @param {object} obj - Object representing the engine configation. + * @returns {boolean} - Whether the engine should be default. + */ + _isDefault(obj) { + return "default" in obj && obj.default === "yes"; + } + + /** + * Object.assign but ignore some keys + * @param {object} target - Object to copy to. + * @param {object} source - Object top copy from. + * @returns {object} - The source object. + */ + _copyObject(target, source) { + for (let key in source) { + if (["included", "excluded", "appliesTo"].includes(key)) { + continue; + } + if (key == "webExtension") { + if (key in target) { + this._copyObject(target[key], source[key]); + } else { + target[key] = { ...source[key] }; + } + } else { + target[key] = source[key]; + } + } + return target; + } + + /** + * Determines wether the section of the config applies to a user + * given what region + locale they are using. + * @param {string} region - The region the user is in. + * @param {string} locale - The language the user has configured. + * @param {object} config - Section of configuration. + * @returns {boolean} - Does the section apply for the region + locale. + */ + _isInSection(region, locale, config) { + if (!config) { + return false; + } + if (config.everywhere) { + return true; + } + let locales = config.locales || {}; + let inLocales = + "matches" in locales && + !!locales.matches.find(e => e.toLowerCase() == locale); + let inRegions = + "regions" in config && + !!config.regions.find(e => e.toLowerCase() == region); + if ( + locales.startsWith && + locales.startsWith.some(key => locale.startsWith(key)) + ) { + inLocales = true; + } + if (config.locales && config.regions) { + return inLocales && inRegions; + } + return inLocales || inRegions; + } +} diff --git a/toolkit/components/search/SearchService.jsm b/toolkit/components/search/SearchService.jsm new file mode 100644 index 0000000000..f9083e0213 --- /dev/null +++ b/toolkit/components/search/SearchService.jsm @@ -0,0 +1,2838 @@ +/* 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/. */ + +/* eslint no-shadow: error, mozilla/no-aArgs: error */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + AddonManager: "resource://gre/modules/AddonManager.jsm", + IgnoreLists: "resource://gre/modules/IgnoreLists.jsm", + OpenSearchEngine: "resource://gre/modules/OpenSearchEngine.jsm", + OS: "resource://gre/modules/osfile.jsm", + Region: "resource://gre/modules/Region.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + SearchEngine: "resource://gre/modules/SearchEngine.jsm", + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", + SearchSettings: "resource://gre/modules/SearchSettings.jsm", + SearchStaticData: "resource://gre/modules/SearchStaticData.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "gExperiment", + SearchUtils.BROWSER_SEARCH_PREF + "experiment", + false, + () => { + Services.search.wrappedJSObject._maybeReloadEngines(); + } +); + +XPCOMUtils.defineLazyGetter(this, "logConsole", () => { + return console.createInstance({ + prefix: "SearchService", + maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +const TOPIC_LOCALES_CHANGE = "intl:app-locales-changed"; +const QUIT_APPLICATION_TOPIC = "quit-application"; + +// The default engine update interval, in days. This is only used if an engine +// specifies an updateURL, but not an updateInterval. +const SEARCH_DEFAULT_UPDATE_INTERVAL = 7; + +// This is the amount of time we'll be idle for before applying any configuration +// changes. +const RECONFIG_IDLE_TIME_SEC = 5 * 60; + +// nsISearchParseSubmissionResult +function ParseSubmissionResult( + engine, + terms, + termsParameterName, + termsOffset, + termsLength +) { + this._engine = engine; + this._terms = terms; + this._termsParameterName = termsParameterName; + this._termsOffset = termsOffset; + this._termsLength = termsLength; +} +ParseSubmissionResult.prototype = { + get engine() { + return this._engine; + }, + get terms() { + return this._terms; + }, + get termsParameterName() { + return this._termsParameterName; + }, + get termsOffset() { + return this._termsOffset; + }, + get termsLength() { + return this._termsLength; + }, + QueryInterface: ChromeUtils.generateQI(["nsISearchParseSubmissionResult"]), +}; + +const gEmptyParseSubmissionResult = Object.freeze( + new ParseSubmissionResult(null, "", "", -1, 0) +); + +/** + * The search service handles loading and maintaining of search engines. It will + * also work out the default lists for each locale/region. + * + * @implements {nsISearchService} + */ +function SearchService() { + this._initObservers = PromiseUtils.defer(); + this._engines = new Map(); + this._settings = new SearchSettings(this); +} + +SearchService.prototype = { + classID: Components.ID("{7319788a-fe93-4db3-9f39-818cf08f4256}"), + + // The current status of initialization. Note that it does not determine if + // initialization is complete, only if an error has been encountered so far. + _initRV: Cr.NS_OK, + + // The boolean indicates that the initialization has started or not. + _initStarted: false, + + // The boolean that indicates if initialization has been completed (successful + // or not). + _initialized: false, + + // Indicates if we're already waiting for maybeReloadEngines to be called. + _maybeReloadDebounce: false, + + // Indicates if we're currently in maybeReloadEngines. + _reloadingEngines: false, + + // The engine selector singleton that is managing the engine configuration. + _engineSelector: null, + + /** + * Various search engines may be ignored if their submission urls contain a + * string that is in the list. The list is controlled via remote settings. + */ + _submissionURLIgnoreList: [], + + /** + * Various search engines may be ignored if their load path is contained + * in this list. The list is controlled via remote settings. + */ + _loadPathIgnoreList: [], + + /** + * A map of engine display names to `SearchEngine`. + */ + _engines: null, + + /** + * An array of engine short names sorted into display order. + */ + __sortedEngines: null, + + /** + * A flag to prevent setting of useSavedOrder when there's non-user + * activity happening. + */ + _dontSetUseSavedOrder: false, + + /** + * An object containing the {id, locale} of the WebExtension for the default + * engine, as suggested by the configuration. + * For the legacy configuration, this is the user visible name. + */ + _searchDefault: null, + + /** + * An object containing the {id, locale} of the WebExtension for the default + * engine for private browsing mode, as suggested by the configuration. + * For the legacy configuration, this is the user visible name. + */ + _searchPrivateDefault: null, + + /** + * A Set of installed search extensions reported by AddonManager + * startup before SearchSevice has started. Will be installed + * during init(). + */ + _startupExtensions: new Set(), + + /** + * A Set of removed search extensions reported by AddonManager + * startup before SearchSevice has started. Will be removed + * during init(). + */ + _startupRemovedExtensions: new Set(), + + // A reference to the handler for the default override allow list. + _defaultOverrideAllowlist: null, + + // This reflects the combined values of the prefs for enabling the separate + // private default UI, and for the user choosing a separate private engine. + // If either one is disabled, then we don't enable the separate private default. + get _separatePrivateDefault() { + return ( + this._separatePrivateDefaultPrefValue && + this._separatePrivateDefaultEnabledPrefValue + ); + }, + + // If initialization has not been completed yet, perform synchronous + // initialization. + // Throws in case of initialization error. + _ensureInitialized() { + if (this._initialized) { + if (!Components.isSuccessCode(this._initRV)) { + logConsole.debug("_ensureInitialized: failure"); + throw Components.Exception( + "SearchService previously failed to initialize", + this._initRV + ); + } + return; + } + + let err = new Error( + "Something tried to use the search service before it's been " + + "properly intialized. Please examine the stack trace to figure out what and " + + "where to fix it:\n" + ); + err.message += err.stack; + throw err; + }, + + /** + * Asynchronous implementation of the initializer. + * + * @returns {number} + * A Components.results success code on success, otherwise a failure code. + */ + async _init() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_separatePrivateDefaultPrefValue", + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + false, + this._onSeparateDefaultPrefChanged.bind(this) + ); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_separatePrivateDefaultEnabledPrefValue", + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + false, + this._onSeparateDefaultPrefChanged.bind(this) + ); + + // We need to catch the region being updated + // during initialisation so we start listening + // straight away. + Services.obs.addObserver(this, Region.REGION_TOPIC); + + try { + // Create the search engine selector. + this._engineSelector = new SearchEngineSelector( + this._handleConfigurationUpdated.bind(this) + ); + + // See if we have a settings file so we don't have to parse a bunch of XML. + let settings = await this._settings.get(); + + this._setupRemoteSettings().catch(Cu.reportError); + + await this._loadEngines(settings); + + // If we've got this far, but the application is now shutting down, + // then we need to abandon any further work, especially not writing + // the settings. We do this, because the add-on manager has also + // started shutting down and as a result, we might have an incomplete + // picture of the installed search engines. Writing the settings at + // this stage would potentially mean the user would loose their engine + // data. + // We will however, rebuild the settings on next start up if we detect + // it is necessary. + if (Services.startup.shuttingDown) { + logConsole.warn("_init: abandoning init due to shutting down"); + this._initRV = Cr.NS_ERROR_ABORT; + this._initObservers.reject(this._initRV); + return this._initRV; + } + + // Make sure the current list of engines is persisted, without the need to wait. + logConsole.debug("_init: engines loaded, writing settings"); + this._addObservers(); + } catch (ex) { + this._initRV = ex.result !== undefined ? ex.result : Cr.NS_ERROR_FAILURE; + logConsole.error("_init: failure initializing search:", ex.result); + } + + this._initialized = true; + if (Components.isSuccessCode(this._initRV)) { + this._initObservers.resolve(this._initRV); + } else { + this._initObservers.reject(this._initRV); + } + Services.obs.notifyObservers( + null, + SearchUtils.TOPIC_SEARCH_SERVICE, + "init-complete" + ); + + logConsole.debug("Completed _init"); + return this._initRV; + }, + + /** + * Obtains the remote settings for the search service. This should only be + * called from init(). Any subsequent updates to the remote settings are + * handled via a sync listener. + * + * For desktop, the initial remote settings are obtained from dumps in + * `services/settings/dumps/main/`. + * + * When enabling for Android, be aware the dumps are not shipped there, and + * hence the `get` may take a while to return. + */ + async _setupRemoteSettings() { + // Now we have the values, listen for future updates. + let listener = this._handleIgnoreListUpdated.bind(this); + + const current = await IgnoreLists.getAndSubscribe(listener); + // Only save the listener after the subscribe, otherwise for tests it might + // not be fully set up by the time we remove it again. + this._ignoreListListener = listener; + + await this._handleIgnoreListUpdated({ data: { current } }); + Services.obs.notifyObservers( + null, + SearchUtils.TOPIC_SEARCH_SERVICE, + "settings-update-complete" + ); + }, + + /** + * This handles updating of the ignore list settings, and removing any ignored + * engines. + * + * @param {object} eventData + * The event in the format received from RemoteSettings. + */ + async _handleIgnoreListUpdated(eventData) { + logConsole.debug("_handleIgnoreListUpdated"); + const { + data: { current }, + } = eventData; + + for (const entry of current) { + if (entry.id == "load-paths") { + this._loadPathIgnoreList = [...entry.matches]; + } else if (entry.id == "submission-urls") { + this._submissionURLIgnoreList = [...entry.matches]; + } + } + + // If we have not finished initializing, then we wait for the initialization + // to complete. + if (!this.isInitialized) { + await this._initObservers; + } + // We try to remove engines manually, as this should be more efficient and + // we don't really want to cause a re-init as this upsets unit tests. + let engineRemoved = false; + for (let engine of this._engines.values()) { + if (this._engineMatchesIgnoreLists(engine)) { + await this.removeEngine(engine); + engineRemoved = true; + } + } + // If we've removed an engine, and we don't have any left, we need to + // reload the engines - it is possible the settings just had one engine in it, + // and that is now empty, so we need to load from our main list. + if (engineRemoved && !this._engines.size) { + this._maybeReloadEngines().catch(Cu.reportError); + } + }, + + /** + * Determines if a given engine matches the ignorelists or not. + * + * @param {Engine} engine + * The engine to check against the ignorelists. + * @returns {boolean} + * Returns true if the engine matches a ignorelists entry. + */ + _engineMatchesIgnoreLists(engine) { + if (this._loadPathIgnoreList.includes(engine._loadPath)) { + return true; + } + let url = engine + ._getURLOfType("text/html") + .getSubmission("dummy", engine) + .uri.spec.toLowerCase(); + if ( + this._submissionURLIgnoreList.some(code => + url.includes(code.toLowerCase()) + ) + ) { + return true; + } + return false; + }, + + async maybeSetAndOverrideDefault(extension) { + let searchProvider = + extension.manifest.chrome_settings_overrides.search_provider; + let engine = this._engines.get(searchProvider.name); + if (!engine || !engine.isAppProvided || engine.hidden) { + // If the engine is not application provided, then we shouldn't simply + // set default to it. + // If the engine is application provided, but hidden, then we don't + // switch to it, nor do we try to install it. + return { + canChangeToAppProvided: false, + canInstallEngine: !engine?.hidden, + }; + } + + if (!this._defaultOverrideAllowlist) { + this._defaultOverrideAllowlist = new SearchDefaultOverrideAllowlistHandler(); + } + + if ( + extension.startupReason === "ADDON_INSTALL" || + extension.startupReason === "ADDON_ENABLE" + ) { + // Don't allow an extension to set the default if it is already the default. + if (this.defaultEngine.name == searchProvider.name) { + return { + canChangeToAppProvided: false, + canInstallEngine: false, + }; + } + if ( + !(await this._defaultOverrideAllowlist.canOverride( + extension, + engine._extensionID + )) + ) { + logConsole.debug( + "Allowing default engine to be set to app-provided.", + extension.id + ); + // We don't allow overriding the engine in this case, but we can allow + // the extension to change the default engine. + return { + canChangeToAppProvided: true, + canInstallEngine: false, + }; + } + // We're ok to override. + engine.overrideWithExtension(extension.id, extension.manifest); + logConsole.debug( + "Allowing default engine to be set to app-provided and overridden.", + extension.id + ); + return { + canChangeToAppProvided: true, + canInstallEngine: false, + }; + } + + if ( + engine.getAttr("overriddenBy") == extension.id && + (await this._defaultOverrideAllowlist.canOverride( + extension, + engine._extensionID + )) + ) { + engine.overrideWithExtension(extension.id, extension.manifest); + logConsole.debug( + "Re-enabling overriding of core extension by", + extension.id + ); + return { + canChangeToAppProvided: true, + canInstallEngine: false, + }; + } + + return { + canChangeToAppProvided: false, + canInstallEngine: false, + }; + }, + + /** + * Handles the search configuration being - adds a wait on the user + * being idle, before the search engine update gets handled. + */ + _handleConfigurationUpdated() { + if (this._queuedIdle) { + return; + } + + this._queuedIdle = true; + + this.idleService.addIdleObserver(this, RECONFIG_IDLE_TIME_SEC); + }, + + get _sortedEngines() { + if (!this.__sortedEngines) { + return this._buildSortedEngineList(); + } + return this.__sortedEngines; + }, + + /** + * Returns the engine that is the default for this locale/region, ignoring any + * user changes to the default engine. + * + * @param {boolean} privateMode + * Set to true to return the default engine in private mode, + * false for normal mode. + * @returns {SearchEngine} + * The engine that is default. + */ + _originalDefaultEngine(privateMode = false) { + let defaultEngine = this._getEngineByWebExtensionDetails( + privateMode && this._searchPrivateDefault + ? this._searchPrivateDefault + : this._searchDefault + ); + + if (defaultEngine) { + return defaultEngine; + } + + if (privateMode) { + // If for some reason we can't find the private mode engine, fall back + // to the non-private one. + return this._originalDefaultEngine(false); + } + + // Something unexpected as happened. In order to recover the original + // default engine, use the first visible engine which is the best we can do. + return this._getSortedEngines(false)[0]; + }, + + /** + * @returns {SearchEngine} + * The engine that is the default for this locale/region, ignoring any + * user changes to the default engine. + */ + get originalDefaultEngine() { + return this._originalDefaultEngine(); + }, + + /** + * @returns {SearchEngine} + * The engine that is the default for this locale/region in private browsing + * mode, ignoring any user changes to the default engine. + * Note: if there is no default for this locale/region, then the non-private + * browsing engine will be returned. + */ + get originalPrivateDefaultEngine() { + return this._originalDefaultEngine(this._separatePrivateDefault); + }, + + resetToOriginalDefaultEngine() { + let originalDefaultEngine = this.originalDefaultEngine; + originalDefaultEngine.hidden = false; + this.defaultEngine = originalDefaultEngine; + }, + + /** + * Loads engines asynchronously. + * + * @param {object} settings + * An object representing the search engine settings. + */ + async _loadEngines(settings) { + logConsole.debug("_loadEngines: start"); + let { engines, privateDefault } = await this._fetchEngineSelectorEngines(); + this._setDefaultAndOrdersFromSelector(engines, privateDefault); + + let newEngines = await this._loadEnginesFromConfig(engines); + for (let engine of newEngines) { + this._addEngineToStore(engine); + } + + logConsole.debug( + "_loadEngines: loading", + this._startupExtensions.size, + "engines reported by AddonManager startup" + ); + for (let extension of this._startupExtensions) { + await this._installExtensionEngine( + extension, + [SearchUtils.DEFAULT_TAG], + true + ); + } + this._startupExtensions.clear(); + + this._loadEnginesFromSettings(settings.engines); + + this._loadEnginesMetadataFromSettings(settings.engines); + + logConsole.debug("_loadEngines: done"); + }, + + /** + * Loads engines as specified by the configuration. We only expect + * configured engines here, user engines should not be listed. + * + * @param {array} engineConfigs + * An array of engines configurations based on the schema. + * @returns {array.<nsISearchEngine>} + * Returns an array of the loaded search engines. This may be + * smaller than the original list if not all engines can be loaded. + */ + async _loadEnginesFromConfig(engineConfigs) { + logConsole.debug("_loadEnginesFromConfig"); + let engines = []; + for (let config of engineConfigs) { + try { + let engine = await this.makeEngineFromConfig(config); + engines.push(engine); + } catch (ex) { + console.error( + `Could not load engine ${ + "webExtension" in config ? config.webExtension.id : "unknown" + }: ${ex}` + ); + } + } + return engines; + }, + + /** + * Reloads engines asynchronously, but only when + * the service has already been initialized. + */ + async _maybeReloadEngines() { + if (this._maybeReloadDebounce) { + logConsole.debug("We're already waiting to reload engines."); + return; + } + + if (!this._initialized || this._reloadingEngines) { + this._maybeReloadDebounce = true; + // Schedule a reload to happen at most 10 seconds after the current run. + Services.tm.idleDispatchToMainThread(() => { + if (!this._maybeReloadDebounce) { + return; + } + this._maybeReloadDebounce = false; + this._maybeReloadEngines().catch(Cu.reportError); + }, 10000); + logConsole.debug( + "Post-poning maybeReloadEngines() as we're currently initializing." + ); + return; + } + + // Before entering `_reloadingEngines` get the settings which we'll need. + // This also ensures that any pending settings have finished being written, + // which could otherwise cause data loss. + let settings = await this._settings.get(); + + logConsole.debug("Running maybeReloadEngines"); + this._reloadingEngines = true; + + try { + await this._reloadEngines(settings); + } catch (ex) { + logConsole.error("maybeReloadEngines failed", ex); + } + this._reloadingEngines = false; + logConsole.debug("maybeReloadEngines complete"); + }, + + async _reloadEngines(settings) { + // Capture the current engine state, in case we need to notify below. + const prevCurrentEngine = this._currentEngine; + const prevPrivateEngine = this._currentPrivateEngine; + + // Ensure that we don't set the useSavedOrder flag whilst we're doing this. + // This isn't a user action, so we shouldn't be switching it. + this._dontSetUseSavedOrder = true; + + // The order of work here is designed to avoid potential issues when updating + // the default engines, so that we're not removing active defaults or trying + // to set a default to something that hasn't been added yet. The order is: + // + // 1) Update exising engines that are in both the old and new configuration. + // 2) Add any new engines from the new configuration. + // 3) Update the default engines. + // 4) Remove any old engines. + + let { + engines: originalConfigEngines, + privateDefault, + } = await this._fetchEngineSelectorEngines(); + + let enginesToRemove = []; + let configEngines = [...originalConfigEngines]; + let oldEngineList = [...this._engines.values()]; + + for (let engine of oldEngineList) { + if (!engine.isAppProvided) { + continue; + } + + let index = configEngines.findIndex( + e => + e.webExtension.id == engine._extensionID && + e.webExtension.locale == engine._locale + ); + + let policy, manifest, locale; + if (index == -1) { + // No engines directly match on id and locale, however, check to see + // if we have a new entry that matches on id and name - we might just + // be swapping the in-use locale. + let replacementEngines = configEngines.filter( + e => e.webExtension.id == engine._extensionID + ); + // If there's no possible, or more than one, we treat these as distinct + // engines so we'll remove the existing engine and add new later if + // necessary. + if (replacementEngines.length != 1) { + enginesToRemove.push(engine); + continue; + } + + policy = await this._getExtensionPolicy(engine._extensionID); + manifest = policy.extension.manifest; + locale = + replacementEngines[0].webExtension.locale || SearchUtils.DEFAULT_TAG; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await policy.extension.getLocalizedManifest(locale); + } + if ( + manifest.name != + manifest.chrome_settings_overrides.search_provider.name.trim() + ) { + // No matching name, so just remove it. + enginesToRemove.push(engine); + continue; + } + + // Update the index so we can handle the updating below. + index = configEngines.findIndex( + e => + e.webExtension.id == replacementEngines[0].webExtension.id && + e.webExtension.locale == replacementEngines[0].webExtension.locale + ); + } else { + // This is an existing engine that we should update (we don't know if + // the configuration for this engine has changed or not). + policy = await this._getExtensionPolicy(engine._extensionID); + + manifest = policy.extension.manifest; + locale = engine._locale || SearchUtils.DEFAULT_TAG; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await policy.extension.getLocalizedManifest(locale); + } + } + engine._updateFromManifest( + policy.extension.id, + policy.extension.baseURI, + manifest, + locale, + configEngines[index] + ); + + configEngines.splice(index, 1); + } + + // Any remaining configuration engines are ones that we need to add. + for (let engine of configEngines) { + try { + let newEngine = await this.makeEngineFromConfig(engine); + this._addEngineToStore(newEngine, true); + } catch (ex) { + logConsole.warn( + `Could not load engine ${ + "webExtension" in engine ? engine.webExtension.id : "unknown" + }: ${ex}` + ); + } + } + this._loadEnginesMetadataFromSettings(settings.engines); + + // Now set the sort out the default engines and notify as appropriate. + this._currentEngine = null; + this._currentPrivateEngine = null; + + this._setDefaultAndOrdersFromSelector( + originalConfigEngines, + privateDefault + ); + + // If the defaultEngine has changed between the previous load and this one, + // dispatch the appropriate notifications. + if (prevCurrentEngine && this.defaultEngine !== prevCurrentEngine) { + SearchUtils.notifyAction( + this._currentEngine, + SearchUtils.MODIFIED_TYPE.DEFAULT + ); + // If we've not got a separate private active, notify update of the + // private so that the UI updates correctly. + if (!this._separatePrivateDefault) { + SearchUtils.notifyAction( + this._currentEngine, + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + } + if ( + this._separatePrivateDefault && + prevPrivateEngine && + this.defaultPrivateEngine !== prevPrivateEngine + ) { + SearchUtils.notifyAction( + this._currentPrivateEngine, + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + + // Finally, remove any engines that need removing. + + for (let engine of enginesToRemove) { + // If we have other engines that use the same extension ID, then + // we do not want to remove the add-on - only remove the engine itself. + let inUseEngines = [...this._engines.values()].filter( + e => e._extensionID == engine._extensionID + ); + + if (inUseEngines.length <= 1) { + if (inUseEngines.length == 1 && inUseEngines[0] == engine) { + // No other engines are using this extension ID. + + // The internal remove is done first to avoid a call to removeEngine + // which could adjust the sort order when we don't want it to. + this._internalRemoveEngine(engine); + + let addon = await AddonManager.getAddonByID(engine._extensionID); + if (addon) { + // AddonManager won't call removeEngine if an engine with the + // WebExtension id doesn't exist in the search service. + await addon.uninstall(); + } + } + // For the case where `inUseEngines[0] != engine`: + // This is a situation where there was an engine added earlier in this + // function with the same name. + // For example, eBay has the same name for both US and GB, but has + // a different domain and uses a different locale of the same + // WebExtension. + // The result of this is the earlier addition has already replaced + // the engine in `this._engines` (which is indexed by name), so all that + // needs to be done here is to pretend the old engine was removed + // which is notified below. + } else { + // More than one engine is using this extension ID, so we don't want to + // remove the add-on. + this._internalRemoveEngine(engine); + } + SearchUtils.notifyAction(engine, SearchUtils.MODIFIED_TYPE.REMOVED); + } + + this._dontSetUseSavedOrder = false; + // Clear out the sorted engines settings, so that we re-sort it if necessary. + this.__sortedEngines = null; + Services.obs.notifyObservers( + null, + SearchUtils.TOPIC_SEARCH_SERVICE, + "engines-reloaded" + ); + }, + + /** + * Test only - reset SearchService data. Ideally this should be replaced + */ + reset() { + this._initialized = false; + this._initObservers = PromiseUtils.defer(); + this._initStarted = false; + this._startupExtensions = new Set(); + this._engines.clear(); + this.__sortedEngines = null; + this._currentEngine = null; + this._currentPrivateEngine = null; + this._searchDefault = null; + this._searchPrivateDefault = null; + this._maybeReloadDebounce = false; + }, + + _addEngineToStore(engine, skipDuplicateCheck = false) { + if (this._engineMatchesIgnoreLists(engine)) { + logConsole.debug("_addEngineToStore: Ignoring engine"); + return; + } + + logConsole.debug("_addEngineToStore: Adding engine:", engine.name); + + // See if there is an existing engine with the same name. However, if this + // engine is updating another engine, it's allowed to have the same name. + var hasSameNameAsUpdate = + engine._engineToUpdate && engine.name == engine._engineToUpdate.name; + if ( + !skipDuplicateCheck && + this._engines.has(engine.name) && + !hasSameNameAsUpdate + ) { + logConsole.debug("_addEngineToStore: Duplicate engine found, aborting!"); + return; + } + + if (engine._engineToUpdate) { + // We need to replace engineToUpdate with the engine that just loaded. + var oldEngine = engine._engineToUpdate; + + // Remove the old engine from the hash, since it's keyed by name, and our + // name might change (the update might have a new name). + this._engines.delete(oldEngine.name); + + // Hack: we want to replace the old engine with the new one, but since + // people may be holding refs to the nsISearchEngine objects themselves, + // we'll just copy over all "private" properties (those without a getter + // or setter) from one object to the other. + for (var p in engine) { + if (!(engine.__lookupGetter__(p) || engine.__lookupSetter__(p))) { + oldEngine[p] = engine[p]; + } + } + engine = oldEngine; + engine._engineToUpdate = null; + + // Add the engine back + this._engines.set(engine.name, engine); + SearchUtils.notifyAction(engine, SearchUtils.MODIFIED_TYPE.CHANGED); + } else { + // Not an update, just add the new engine. + this._engines.set(engine.name, engine); + // Only add the engine to the list of sorted engines if the initial list + // has already been built (i.e. if this.__sortedEngines is non-null). If + // it hasn't, we're loading engines from disk and the sorted engine list + // will be built once we need it. + if (this.__sortedEngines && !this._dontSetUseSavedOrder) { + this.__sortedEngines.push(engine); + this._saveSortedEngineList(); + } + SearchUtils.notifyAction(engine, SearchUtils.MODIFIED_TYPE.ADDED); + } + + // Let the engine know it can start notifying new updates. + engine._engineAddedToStore = true; + + if (engine._hasUpdates) { + // Schedule the engine's next update, if it isn't already. + if (!engine.getAttr("updateexpir")) { + engineUpdateService.scheduleNextUpdate(engine); + } + } + }, + + _loadEnginesMetadataFromSettings(engines) { + if (!engines) { + return; + } + + for (let engine of engines) { + let name = engine._name; + if (this._engines.has(name)) { + logConsole.debug( + "_loadEnginesMetadataFromSettings, transfering metadata for", + name + ); + let eng = this._engines.get(name); + // We used to store the alias in metadata.alias, in 1621892 that was + // changed to only store the user set alias in metadata.alias, remove + // it from metadata if it was previously set to the internal value. + if (eng._alias === engine?._metaData?.alias) { + delete engine._metaData.alias; + } + eng._metaData = engine._metaData || {}; + } + } + }, + + _loadEnginesFromSettings(enginesCache) { + if (!enginesCache) { + return; + } + + logConsole.debug( + "_loadEnginesFromSettings: Loading", + enginesCache.length, + "engines from settings" + ); + + let skippedEngines = 0; + for (let engineJSON of enginesCache) { + // We renamed isBuiltin to isAppProvided in 1631898, + // keep checking isBuiltin for older settings. + if (engineJSON._isAppProvided || engineJSON._isBuiltin) { + ++skippedEngines; + continue; + } + + // Some OpenSearch type engines are now obsolete and no longer supported. + // These were application provided engines that used to use the OpenSearch + // format before gecko transitioned to WebExtensions. + // These will sometimes have been missed in migration due to various + // reasons, and due to how the settings saves everything. We therefore + // explicitly ignore them here to drop them, and let the rest of the code + // fallback to the application/distribution default if necessary. + let loadPath = engineJSON._loadPath?.toLowerCase(); + if ( + loadPath && + // Replaced by application provided in Firefox 79. + (loadPath.startsWith("[distribution]") || + // Langpack engines moved in-app in Firefox 62. + // Note: these may be prefixed by jar:, + loadPath.includes("[app]/extensions/langpack") || + loadPath.includes("[other]/langpack") || + loadPath.includes("[profile]/extensions/langpack")) + ) { + continue; + } + + try { + let engine = new SearchEngine({ + isAppProvided: false, + loadPath: engineJSON._loadPath, + }); + engine._initWithJSON(engineJSON); + this._addEngineToStore(engine); + } catch (ex) { + logConsole.error( + "Failed to load", + engineJSON._name, + "from settings:", + ex, + engineJSON + ); + } + } + + if (skippedEngines) { + logConsole.debug( + "_loadEnginesFromSettings: skipped", + skippedEngines, + "built-in engines." + ); + } + }, + + async _fetchEngineSelectorEngines() { + let locale = Services.locale.appLocaleAsBCP47; + let region = Region.home || "default"; + + let channel = AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "esr" + : AppConstants.MOZ_UPDATE_CHANNEL; + + let { + engines, + privateDefault, + } = await this._engineSelector.fetchEngineConfiguration({ + locale, + region, + channel, + experiment: gExperiment, + distroID: SearchUtils.distroID, + }); + + for (let e of engines) { + if (!e.webExtension) { + e.webExtension = {}; + } + e.webExtension.locale = e.webExtension?.locale ?? SearchUtils.DEFAULT_TAG; + } + + return { engines, privateDefault }; + }, + + _setDefaultAndOrdersFromSelector(engines, privateDefault) { + const defaultEngine = engines[0]; + this._searchDefault = { + id: defaultEngine.webExtension.id, + locale: defaultEngine.webExtension.locale, + }; + if (privateDefault) { + this._searchPrivateDefault = { + id: privateDefault.webExtension.id, + locale: privateDefault.webExtension.locale, + }; + } + }, + + _saveSortedEngineList() { + logConsole.debug("_saveSortedEngineList"); + + // Set the useSavedOrder attribute to indicate that from now on we should + // use the user's order information stored in settings. + this._settings.setAttribute("useSavedOrder", true); + + var engines = this._getSortedEngines(true); + + for (var i = 0; i < engines.length; ++i) { + engines[i].setAttr("order", i + 1); + } + }, + + _buildSortedEngineList() { + // We must initialise __sortedEngines here to avoid infinite recursion + // in the case of tests which don't define a default search engine. + // If there's no default defined, then we revert to the first item in the + // sorted list, but we can't do that if we don't have a list. + this.__sortedEngines = []; + + // If the user has specified a custom engine order, read the order + // information from the metadata instead of the default prefs. + if (this._settings.getAttribute("useSavedOrder")) { + logConsole.debug("_buildSortedEngineList: using saved order"); + let addedEngines = {}; + + // Flag to keep track of whether or not we need to call _saveSortedEngineList. + let needToSaveEngineList = false; + + for (let engine of this._engines.values()) { + var orderNumber = engine.getAttr("order"); + + // Since the DB isn't regularly cleared, and engine files may disappear + // without us knowing, we may already have an engine in this slot. If + // that happens, we just skip it - it will be added later on as an + // unsorted engine. + if (orderNumber && !this.__sortedEngines[orderNumber - 1]) { + this.__sortedEngines[orderNumber - 1] = engine; + addedEngines[engine.name] = engine; + } else { + // We need to call _saveSortedEngineList so this gets sorted out. + needToSaveEngineList = true; + } + } + + // Filter out any nulls for engines that may have been removed + var filteredEngines = this.__sortedEngines.filter(function(a) { + return !!a; + }); + if (this.__sortedEngines.length != filteredEngines.length) { + needToSaveEngineList = true; + } + this.__sortedEngines = filteredEngines; + + if (needToSaveEngineList) { + this._saveSortedEngineList(); + } + + // Array for the remaining engines, alphabetically sorted. + let alphaEngines = []; + + for (let engine of this._engines.values()) { + if (!(engine.name in addedEngines)) { + alphaEngines.push(engine); + } + } + + const collator = new Intl.Collator(); + alphaEngines.sort((a, b) => { + return collator.compare(a.name, b.name); + }); + return (this.__sortedEngines = this.__sortedEngines.concat(alphaEngines)); + } + logConsole.debug("_buildSortedEngineList: using default orders"); + + return (this.__sortedEngines = this._sortEnginesByDefaults( + Array.from(this._engines.values()) + )); + }, + + /** + * Sorts engines by the default settings (prefs, configuration values). + * + * @param {Array} engines + * An array of engine objects to sort. + * @returns {Array} + * The sorted array of engine objects. + */ + _sortEnginesByDefaults(engines) { + const sortedEngines = []; + const addedEngines = new Set(); + + function maybeAddEngineToSort(engine) { + if (!engine || addedEngines.has(engine.name)) { + return; + } + + sortedEngines.push(engine); + addedEngines.add(engine.name); + } + + // The original default engine should always be first in the list (except + // for distros, that we should respect). + const originalDefault = this.originalDefaultEngine; + maybeAddEngineToSort(originalDefault); + + // If there's a private default, and it is different to the normal + // default, then it should be second in the list. + const originalPrivateDefault = this.originalPrivateDefaultEngine; + if (originalPrivateDefault && originalPrivateDefault != originalDefault) { + maybeAddEngineToSort(originalPrivateDefault); + } + + let remainingEngines; + const collator = new Intl.Collator(); + + remainingEngines = engines.filter(e => !addedEngines.has(e.name)); + + // We sort by highest orderHint first, then alphabetically by name. + remainingEngines.sort((a, b) => { + if (a._orderHint && b._orderHint) { + if (a._orderHint == b._orderHint) { + return collator.compare(a.name, b.name); + } + return b._orderHint - a._orderHint; + } + if (a._orderHint) { + return -1; + } + if (b._orderHint) { + return 1; + } + return collator.compare(a.name, b.name); + }); + + return [...sortedEngines, ...remainingEngines]; + }, + + /** + * Get a sorted array of engines. + * + * @param {boolean} withHidden + * True if hidden plugins should be included in the result. + * @returns {Array<SearchEngine>} + * The sorted array. + */ + _getSortedEngines(withHidden) { + if (withHidden) { + return this._sortedEngines; + } + + return this._sortedEngines.filter(function(engine) { + return !engine.hidden; + }); + }, + + // nsISearchService + async init() { + logConsole.debug("init"); + if (this._initStarted) { + return this._initObservers.promise; + } + + TelemetryStopwatch.start("SEARCH_SERVICE_INIT_MS"); + this._initStarted = true; + try { + // Complete initialization by calling asynchronous initializer. + await this._init(); + TelemetryStopwatch.finish("SEARCH_SERVICE_INIT_MS"); + } catch (ex) { + Services.telemetry.scalarSet( + "browser.searchinit.init_result_status_code", + // Scalar is a string due to bug 1651210 when the scalar was created. + ex.result?.toString(10) + ); + TelemetryStopwatch.cancel("SEARCH_SERVICE_INIT_MS"); + this._initObservers.reject(ex.result); + throw ex; + } + Services.telemetry.scalarSet( + "browser.searchinit.init_result_status_code", + // Scalar is a string due to bug 1651210 when the scalar was created. + this._initRV?.toString(10) + ); + + if (!Components.isSuccessCode(this._initRV)) { + throw Components.Exception( + "SearchService initialization failed", + this._initRV + ); + } else if (this._startupRemovedExtensions.size) { + Services.tm.dispatchToMainThread(async () => { + // Now that init() has successfully finished, we remove any engines + // that have had their add-ons removed by the add-on manager. + // We do this after init() has complete, as that allows us to use + // removeEngine to look after any default engine changes as well. + // This could cause a slight flicker on startup, but it should be + // a rare action. + logConsole.debug("Removing delayed extension engines"); + for (let id of this._startupRemovedExtensions) { + for (let engine of this._getEnginesByExtensionID(id)) { + // Only do this for non-application provided engines. We shouldn't + // ever get application provided engines removed here, but just in case. + if (!engine.isAppProvided) { + await this.removeEngine(engine); + } + } + } + this._startupRemovedExtensions.clear(); + }); + } + return this._initRV; + }, + + get isInitialized() { + return this._initialized; + }, + + /** + * Checks if Search Engines associated with WebExtensions are valid and + * up-to-date, and reports them via telemetry if not. + */ + async checkWebExtensionEngines() { + await this.init(); + logConsole.debug("Running check on WebExtension engines"); + + for (let engine of this._engines.values()) { + if ( + engine.isAppProvided || + !engine._extensionID || + engine._extensionID == "set-via-policy" || + engine._extensionID == "set-via-user" + ) { + continue; + } + + let addon = await AddonManager.getAddonByID(engine._extensionID); + + if (!addon) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} is not installed!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 1 + ); + } else if (!addon.isActive) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} is not active!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 2 + ); + } else { + let policy = await this._getExtensionPolicy(engine._extensionID); + let providerSettings = + policy.extension.manifest?.chrome_settings_overrides?.search_provider; + + if (!providerSettings) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} no longer has an engine defined` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 4 + ); + } else if (engine.name != providerSettings.name) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} has a different name!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 5 + ); + } else if (!engine.checkSearchUrlMatchesManifest(providerSettings)) { + logConsole.debug( + `Add-on ${engine._extensionID} for search engine ${engine.name} has out-of-date manifest!` + ); + Services.telemetry.keyedScalarSet( + "browser.searchinit.engine_invalid_webextension", + engine._extensionID, + 6 + ); + } + } + } + logConsole.debug("WebExtension engine check complete"); + }, + + async getEngines() { + await this.init(); + logConsole.debug("getEngines: getting all engines"); + return this._getSortedEngines(true); + }, + + async getVisibleEngines() { + await this.init(true); + logConsole.debug("getVisibleEngines: getting all visible engines"); + return this._getSortedEngines(false); + }, + + async getAppProvidedEngines() { + await this.init(); + + return this._sortEnginesByDefaults( + this._sortedEngines.filter(e => e.isAppProvided) + ); + }, + + async getEnginesByExtensionID(extensionID) { + await this.init(); + return this._getEnginesByExtensionID(extensionID); + }, + + _getEnginesByExtensionID(extensionID) { + logConsole.debug("getEngines: getting all engines for", extensionID); + var engines = this._getSortedEngines(true).filter(function(engine) { + return engine._extensionID == extensionID; + }); + return engines; + }, + + /** + * Returns the engine associated with the name. + * + * @param {string} engineName + * The name of the engine. + * @returns {SearchEngine} + * The associated engine if found, null otherwise. + */ + getEngineByName(engineName) { + this._ensureInitialized(); + return this._engines.get(engineName) || null; + }, + + async getEngineByAlias(alias) { + await this.init(); + for (var engine of this._engines.values()) { + if (engine && engine.aliases.includes(alias)) { + return engine; + } + } + return null; + }, + + /** + * Returns the engine associated with the WebExtension details. + * + * @param {object} details + * @param {string} details.id + * The WebExtension ID + * @param {string} details.locale + * The WebExtension locale + * @returns {nsISearchEngine|null} + * The found engine, or null if no engine matched. + */ + _getEngineByWebExtensionDetails(details) { + for (const engine of this._engines.values()) { + if ( + engine._extensionID == details.id && + engine._locale == details.locale + ) { + return engine; + } + } + return null; + }, + + /** + * Adds a search engine that is specified from enterprise policies. + * + * @param {object} details + * An object that simulates the manifest object from a WebExtension. See + * the idl for more details. + */ + async addPolicyEngine(details) { + await this._createAndAddEngine({ + extensionID: "set-via-policy", + extensionBaseURI: "", + isAppProvided: false, + manifest: details, + }); + }, + + /** + * Adds a search engine that is specified by the user. + * + * @param {string} name + * @param {string} url + * @param {string} alias + */ + async addUserEngine(name, url, alias) { + await this._createAndAddEngine({ + extensionID: "set-via-user", + extensionBaseURI: "", + isAppProvided: false, + manifest: { + chrome_settings_overrides: { + search_provider: { + name, + search_url: encodeURI(url), + keyword: alias, + }, + }, + }, + }); + }, + + /** + * Adds an engine with specific details, only used for tests and should + * be considered obsolete, see bug 1649186. + * + * @param {string} name + * The name of the engine to add. + * @param {object} details + * The details of the engine to add. + */ + async addEngineWithDetails(name, details) { + let manifest = { + description: details.description, + iconURL: details.iconURL, + chrome_settings_overrides: { + search_provider: { + name, + encoding: details.encoding || SearchUtils.DEFAULT_QUERY_CHARSET, + search_url: encodeURI(details.template), + keyword: details.alias, + search_url_get_params: details.searchGetParams, + search_url_post_params: details.postData || details.searchPostParams, + suggest_url: details.suggestURL, + }, + }, + }; + return this._createAndAddEngine({ + extensionID: details.extensionID ?? `${name}@test.engine`, + extensionBaseURI: "", + isAppProvided: false, + manifest, + }); + }, + + /** + * Creates and adds a WebExtension based engine. + * Note: this is currently used for enterprise policy engines as well. + * + * @param {object} options + * @param {string} options.extensionID + * The extension ID being added for the engine. + * @param {nsIURI} [options.extensionBaseURI] + * The base URI of the extension. + * @param {boolean} options.isAppProvided + * True if the WebExtension is built-in or installed into the system scope. + * @param {object} options.manifest + * An object that represents the extension's manifest. + * @param {stirng} [options.locale] + * The locale to use within the WebExtension. Defaults to the WebExtension's + * default locale. + * @param {initEngine} [options.initEngine] + * Set to true if this engine is being loaded during initialisation. + */ + async _createAndAddEngine({ + extensionID, + extensionBaseURI, + isAppProvided, + manifest, + locale = SearchUtils.DEFAULT_TAG, + initEngine = false, + }) { + if (!extensionID) { + throw Components.Exception( + "Empty extensionID passed to _createAndAddEngine!", + Cr.NS_ERROR_INVALID_ARG + ); + } + let searchProvider = manifest.chrome_settings_overrides.search_provider; + let name = searchProvider.name.trim(); + logConsole.debug("_createAndAddEngine: Adding", name); + let isCurrent = false; + + // We install search extensions during the init phase, both built in + // web extensions freshly installed (via addEnginesFromExtension) or + // user installed extensions being reenabled calling this directly. + if (!this._initialized && !isAppProvided && !initEngine) { + await this.init(); + } + let existingEngine = this._engines.get(name); + if (existingEngine) { + if ( + extensionID && + existingEngine._loadPath.startsWith( + `jar:[profile]/extensions/${extensionID}` + ) + ) { + // This is a legacy extension engine that needs to be migrated to WebExtensions. + isCurrent = this.defaultEngine == existingEngine; + await this.removeEngine(existingEngine); + } else { + throw Components.Exception( + "An engine with that name already exists!", + Cr.NS_ERROR_FILE_ALREADY_EXISTS + ); + } + } + + let newEngine = new SearchEngine({ + name, + isAppProvided, + loadPath: `[other]addEngineWithDetails:${extensionID}`, + }); + newEngine._initFromManifest( + extensionID, + extensionBaseURI, + manifest, + locale + ); + + this._addEngineToStore(newEngine); + if (isCurrent) { + this.defaultEngine = newEngine; + } + return newEngine; + }, + + /** + * Called from the AddonManager when it either installs a new + * extension containing a search engine definition or an upgrade + * to an existing one. + * + * @param {object} extension + * An Extension object containing data about the extension. + */ + async addEnginesFromExtension(extension) { + logConsole.debug("addEnginesFromExtension: " + extension.id); + // Treat add-on upgrade and downgrades the same - either way, the search + // engine gets updated, not added. Generally, we don't expect a downgrade, + // but just in case... + if ( + extension.startupReason == "ADDON_UPGRADE" || + extension.startupReason == "ADDON_DOWNGRADE" + ) { + return this._upgradeExtensionEngine(extension); + } + + if (extension.isAppProvided) { + // If we are in the middle of initialization or reloading engines, + // don't add the engine here. This has been called as the result + // of makeEngineFromConfig installing the extension, and that is already + // handling the addition of the engine. + if (this._initialized && !this._reloadingEngines) { + let { engines } = await this._fetchEngineSelectorEngines(); + let inConfig = engines.filter(el => el.webExtension.id == extension.id); + if (inConfig.length) { + return this._installExtensionEngine( + extension, + inConfig.map(el => el.webExtension.locale) + ); + } + } + logConsole.debug("addEnginesFromExtension: Ignoring builtIn engine."); + return []; + } + + // If we havent started SearchService yet, store this extension + // to install in SearchService.init(). + if (!this._initialized) { + this._startupExtensions.add(extension); + return []; + } + + return this._installExtensionEngine(extension, [SearchUtils.DEFAULT_TAG]); + }, + + /** + * Called when we see an upgrade to an existing search extension. + * + * @param {object} extension + * An Extension object containing data about the extension. + */ + async _upgradeExtensionEngine(extension) { + let { engines } = await this._fetchEngineSelectorEngines(); + let extensionEngines = await this.getEnginesByExtensionID(extension.id); + + for (let engine of extensionEngines) { + let manifest = extension.manifest; + let locale = engine._locale || SearchUtils.DEFAULT_TAG; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await extension.getLocalizedManifest(locale); + } + let configuration = + engines.find( + e => + e.webExtension.id == extension.id && e.webExtension.locale == locale + ) ?? {}; + engine._updateFromManifest( + extension.id, + extension.baseURI, + manifest, + locale, + configuration + ); + } + return extensionEngines; + }, + + /** + * Create an engine object from the search configuration details. + * + * @param {object} config + * The configuration object that defines the details of the engine + * webExtensionId etc. + * @returns {nsISearchEngine} + * Returns the search engine object. + */ + async makeEngineFromConfig(config) { + logConsole.debug("makeEngineFromConfig:", config); + let policy = await this._getExtensionPolicy(config.webExtension.id); + let locale = + "locale" in config.webExtension + ? config.webExtension.locale + : SearchUtils.DEFAULT_TAG; + + let manifest = policy.extension.manifest; + if (locale != SearchUtils.DEFAULT_TAG) { + manifest = await policy.extension.getLocalizedManifest(locale); + } + + let engine = new SearchEngine({ + name: manifest.chrome_settings_overrides.search_provider.name.trim(), + isAppProvided: policy.extension.isAppProvided, + loadPath: `[other]addEngineWithDetails:${policy.extension.id}`, + }); + engine._initFromManifest( + policy.extension.id, + policy.extension.baseURI, + manifest, + locale, + config + ); + return engine; + }, + + async _installExtensionEngine(extension, locales, initEngine = false) { + logConsole.debug("installExtensionEngine:", extension.id); + + let installLocale = async locale => { + let manifest = + locale == SearchUtils.DEFAULT_TAG + ? extension.manifest + : await extension.getLocalizedManifest(locale); + return this._addEngineForManifest( + extension, + manifest, + locale, + initEngine + ); + }; + + let engines = []; + for (let locale of locales) { + logConsole.debug( + "addEnginesFromExtension: installing:", + extension.id, + ":", + locale + ); + engines.push(await installLocale(locale)); + } + return engines; + }, + + async _addEngineForManifest( + extension, + manifest, + locale = SearchUtils.DEFAULT_TAG, + initEngine = false + ) { + // If we're in the startup cycle, and we've already loaded this engine, + // then we use the existing one rather than trying to start from scratch. + // This also avoids console errors. + if (extension.startupReason == "APP_STARTUP") { + let engine = this._getEngineByWebExtensionDetails({ + id: extension.id, + locale, + }); + if (engine) { + logConsole.debug( + "Engine already loaded via settings, skipping due to APP_STARTUP:", + extension.id + ); + return engine; + } + } + + return this._createAndAddEngine({ + extensionID: extension.id, + extensionBaseURI: extension.baseURI, + isAppProvided: extension.isAppProvided, + manifest, + locale, + initEngine, + }); + }, + + async addOpenSearchEngine(engineURL, iconURL) { + logConsole.debug("addEngine: Adding", engineURL); + await this.init(); + let errCode; + try { + var engine = new OpenSearchEngine({ + uri: engineURL, + isAppProvided: false, + }); + engine._setIcon(iconURL, false); + errCode = await new Promise(resolve => { + engine._initFromURIAndLoad(engineURL, errorCode => { + resolve(errorCode); + }); + }); + if (errCode) { + throw errCode; + } + } catch (ex) { + throw Components.Exception( + "addEngine: Error adding engine:\n" + ex, + errCode || Cr.NS_ERROR_FAILURE + ); + } + return engine; + }, + + async removeWebExtensionEngine(id) { + if (!this.isInitialized) { + logConsole.debug("Delaying removing extension engine on startup:", id); + this._startupRemovedExtensions.add(id); + return; + } + + logConsole.debug("removeWebExtensionEngine:", id); + for (let engine of this._getEnginesByExtensionID(id)) { + await this.removeEngine(engine); + } + }, + + async removeEngine(engine) { + await this.init(); + if (!engine) { + throw Components.Exception( + "no engine passed to removeEngine!", + Cr.NS_ERROR_INVALID_ARG + ); + } + + var engineToRemove = null; + for (var e of this._engines.values()) { + if (engine.wrappedJSObject == e) { + engineToRemove = e; + } + } + + if (!engineToRemove) { + throw Components.Exception( + "removeEngine: Can't find engine to remove!", + Cr.NS_ERROR_FILE_NOT_FOUND + ); + } + + if (engineToRemove == this.defaultEngine) { + this._currentEngine = null; + } + + // Bug 1575649 - We can't just check the default private engine here when + // we're not using separate, as that re-checks the normal default, and + // triggers update of the default search engine, which messes up various + // tests. Really, removeEngine should always commit to updating any + // changed defaults. + if ( + this._separatePrivateDefault && + engineToRemove == this.defaultPrivateEngine + ) { + this._currentPrivateEngine = null; + } + + if (engineToRemove._isAppProvided) { + // Just hide it (the "hidden" setter will notify) and remove its alias to + // avoid future conflicts with other engines. + engineToRemove.hidden = true; + engineToRemove.alias = null; + } else { + // Remove the engine file from disk if we had a legacy file in the profile. + if (engineToRemove._filePath) { + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.persistentDescriptor = engineToRemove._filePath; + if (file.exists()) { + file.remove(false); + } + engineToRemove._filePath = null; + } + this._internalRemoveEngine(engineToRemove); + + // Since we removed an engine, we may need to update the preferences. + if (!this._dontSetUseSavedOrder) { + this._saveSortedEngineList(); + } + } + SearchUtils.notifyAction(engineToRemove, SearchUtils.MODIFIED_TYPE.REMOVED); + }, + + _internalRemoveEngine(engine) { + // Remove the engine from _sortedEngines + if (this.__sortedEngines) { + var index = this.__sortedEngines.indexOf(engine); + if (index == -1) { + throw Components.Exception( + "Can't find engine to remove in _sortedEngines!", + Cr.NS_ERROR_FAILURE + ); + } + this.__sortedEngines.splice(index, 1); + } + + // Remove the engine from the internal store + this._engines.delete(engine.name); + }, + + async moveEngine(engine, newIndex) { + await this.init(); + if (newIndex > this._sortedEngines.length || newIndex < 0) { + throw Components.Exception("moveEngine: Index out of bounds!"); + } + if ( + !(engine instanceof Ci.nsISearchEngine) && + !(engine instanceof SearchEngine) + ) { + throw Components.Exception( + "moveEngine: Invalid engine passed to moveEngine!", + Cr.NS_ERROR_INVALID_ARG + ); + } + if (engine.hidden) { + throw Components.Exception( + "moveEngine: Can't move a hidden engine!", + Cr.NS_ERROR_FAILURE + ); + } + + engine = engine.wrappedJSObject; + + var currentIndex = this._sortedEngines.indexOf(engine); + if (currentIndex == -1) { + throw Components.Exception( + "moveEngine: Can't find engine to move!", + Cr.NS_ERROR_UNEXPECTED + ); + } + + // Our callers only take into account non-hidden engines when calculating + // newIndex, but we need to move it in the array of all engines, so we + // need to adjust newIndex accordingly. To do this, we count the number + // of hidden engines in the list before the engine that we're taking the + // place of. We do this by first finding newIndexEngine (the engine that + // we were supposed to replace) and then iterating through the complete + // engine list until we reach it, increasing newIndex for each hidden + // engine we find on our way there. + // + // This could be further simplified by having our caller pass in + // newIndexEngine directly instead of newIndex. + var newIndexEngine = this._getSortedEngines(false)[newIndex]; + if (!newIndexEngine) { + throw Components.Exception( + "moveEngine: Can't find engine to replace!", + Cr.NS_ERROR_UNEXPECTED + ); + } + + for (var i = 0; i < this._sortedEngines.length; ++i) { + if (newIndexEngine == this._sortedEngines[i]) { + break; + } + if (this._sortedEngines[i].hidden) { + newIndex++; + } + } + + if (currentIndex == newIndex) { + return; + } // nothing to do! + + // Move the engine + var movedEngine = this.__sortedEngines.splice(currentIndex, 1)[0]; + this.__sortedEngines.splice(newIndex, 0, movedEngine); + + SearchUtils.notifyAction(engine, SearchUtils.MODIFIED_TYPE.CHANGED); + + // Since we moved an engine, we need to update the preferences. + this._saveSortedEngineList(); + }, + + restoreDefaultEngines() { + this._ensureInitialized(); + for (let e of this._engines.values()) { + // Unhide all default engines + if (e.hidden && e.isAppProvided) { + e.hidden = false; + } + } + }, + + /** + * Helper function to get the current default engine. + * + * @param {boolean} privateMode + * If true, returns the default engine for private browsing mode, otherwise + * the default engine for the normal mode. Note, this function does not + * check the "separatePrivateDefault" preference - that is up to the caller. + * @returns {nsISearchEngine|null} + * The appropriate search engine, or null if one could not be determined. + */ + _getEngineDefault(privateMode) { + this._ensureInitialized(); + const currentEngine = privateMode + ? "_currentPrivateEngine" + : "_currentEngine"; + if (!this[currentEngine]) { + const attributeName = privateMode ? "private" : "current"; + let name = this._settings.getAttribute(attributeName); + let engine = this.getEngineByName(name); + if ( + engine && + (engine.isAppProvided || + this._settings.getVerifiedAttribute(attributeName)) + ) { + // If the current engine is a default one, we can relax the + // verification hash check to reduce the annoyance for users who + // backup/sync their profile in custom ways. + this[currentEngine] = engine; + } + if (!name) { + this[currentEngine] = privateMode + ? this.originalPrivateDefaultEngine + : this.originalDefaultEngine; + } + } + + // If the current engine is not set or hidden, we fallback... + if (!this[currentEngine] || this[currentEngine].hidden) { + // first to the original default engine + let originalDefault = privateMode + ? this.originalPrivateDefaultEngine + : this.originalDefaultEngine; + if (!originalDefault || originalDefault.hidden) { + // then to the first visible engine + let firstVisible = this._getSortedEngines(false)[0]; + if (firstVisible && !firstVisible.hidden) { + if (privateMode) { + this.defaultPrivateEngine = firstVisible; + } else { + this.defaultEngine = firstVisible; + } + return firstVisible; + } + // and finally as a last resort we unhide the original default engine. + if (originalDefault) { + originalDefault.hidden = false; + } + } + if (!originalDefault) { + return null; + } + + // If the current engine wasn't set or was hidden, we used a fallback + // to pick a new current engine. As soon as we return it, this new + // current engine will become user-visible, so we should persist it. + // by calling the setter. + if (privateMode) { + this.defaultPrivateEngine = originalDefault; + } else { + this.defaultEngine = originalDefault; + } + } + + return this[currentEngine]; + }, + + /** + * Helper function to set the current default engine. + * + * @param {boolean} privateMode + * If true, sets the default engine for private browsing mode, otherwise + * sets the default engine for the normal mode. Note, this function does not + * check the "separatePrivateDefault" preference - that is up to the caller. + * @param {nsISearchEngine} newEngine + * The search engine to select + */ + _setEngineDefault(privateMode, newEngine) { + this._ensureInitialized(); + // Sometimes we get wrapped nsISearchEngine objects (external XPCOM callers), + // and sometimes we get raw Engine JS objects (callers in this file), so + // handle both. + if ( + !(newEngine instanceof Ci.nsISearchEngine) && + !(newEngine instanceof SearchEngine) + ) { + throw Components.Exception( + "Invalid argument passed to defaultEngine setter", + Cr.NS_ERROR_INVALID_ARG + ); + } + + const newCurrentEngine = this.getEngineByName(newEngine.name); + if (!newCurrentEngine) { + throw Components.Exception( + "Can't find engine in store!", + Cr.NS_ERROR_UNEXPECTED + ); + } + + if (!newCurrentEngine.isAppProvided) { + // If a non default engine is being set as the current engine, ensure + // its loadPath has a verification hash. + if (!newCurrentEngine._loadPath) { + newCurrentEngine._loadPath = "[other]unknown"; + } + let loadPathHash = SearchUtils.getVerificationHash( + newCurrentEngine._loadPath + ); + let currentHash = newCurrentEngine.getAttr("loadPathHash"); + if (!currentHash || currentHash != loadPathHash) { + newCurrentEngine.setAttr("loadPathHash", loadPathHash); + SearchUtils.notifyAction( + newCurrentEngine, + SearchUtils.MODIFIED_TYPE.CHANGED + ); + } + } + + const currentEngine = `_current${privateMode ? "Private" : ""}Engine`; + + if (newCurrentEngine == this[currentEngine]) { + return; + } + + // Ensure that we reset an engine override if it was previously overridden. + this[currentEngine]?.removeExtensionOverride(); + + this[currentEngine] = newCurrentEngine; + + // If we change the default engine in the future, that change should impact + // users who have switched away from and then back to the build's "default" + // engine. So clear the user pref when the currentEngine is set to the + // build's default engine, so that the currentEngine getter falls back to + // whatever the default is. + let newName = this[currentEngine].name; + const originalDefault = privateMode + ? this.originalPrivateDefaultEngine + : this.originalDefaultEngine; + if (this[currentEngine] == originalDefault) { + newName = ""; + } + + this._settings.setVerifiedAttribute( + privateMode ? "private" : "current", + newName + ); + + SearchUtils.notifyAction( + this[currentEngine], + SearchUtils.MODIFIED_TYPE[privateMode ? "DEFAULT_PRIVATE" : "DEFAULT"] + ); + // If we've not got a separate private active, notify update of the + // private so that the UI updates correctly. + if (!privateMode && !this._separatePrivateDefault) { + SearchUtils.notifyAction( + this[currentEngine], + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + }, + + get defaultEngine() { + return this._getEngineDefault(false); + }, + + set defaultEngine(newEngine) { + this._setEngineDefault(false, newEngine); + }, + + get defaultPrivateEngine() { + return this._getEngineDefault(this._separatePrivateDefault); + }, + + set defaultPrivateEngine(newEngine) { + if (!this._separatePrivateDefaultPrefValue) { + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + } + this._setEngineDefault(this._separatePrivateDefault, newEngine); + }, + + async getDefault() { + await this.init(); + return this.defaultEngine; + }, + + async setDefault(engine) { + await this.init(); + return (this.defaultEngine = engine); + }, + + async getDefaultPrivate() { + await this.init(); + return this.defaultPrivateEngine; + }, + + async setDefaultPrivate(engine) { + await this.init(); + return (this.defaultPrivateEngine = engine); + }, + + _onSeparateDefaultPrefChanged() { + // Clear out the sorted engines settings, so that we re-sort it if necessary. + this.__sortedEngines = null; + // We should notify if the normal default, and the currently saved private + // default are different. Otherwise, save the energy. + if (this.defaultEngine != this._getEngineDefault(true)) { + SearchUtils.notifyAction( + // Always notify with the new private engine, the function checks + // the preference value for us. + this.defaultPrivateEngine, + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + ); + } + }, + + async _getEngineInfo(engine) { + if (!engine) { + // The defaultEngine getter will throw if there's no engine at all, + // which shouldn't happen unless an add-on or a test deleted all of them. + // Our preferences UI doesn't let users do that. + Cu.reportError("getDefaultEngineInfo: No default engine"); + return ["NONE", { name: "NONE" }]; + } + + const engineData = { + loadPath: engine._loadPath, + name: engine.name ? engine.name : "", + }; + + if (engine.isAppProvided) { + engineData.origin = "default"; + } else { + let currentHash = engine.getAttr("loadPathHash"); + if (!currentHash) { + engineData.origin = "unverified"; + } else { + let loadPathHash = SearchUtils.getVerificationHash(engine._loadPath); + engineData.origin = + currentHash == loadPathHash ? "verified" : "invalid"; + } + } + + // For privacy, we only collect the submission URL for default engines... + let sendSubmissionURL = engine.isAppProvided; + + if (!sendSubmissionURL) { + // ... or engines that are the same domain as a default engine. + let engineHost = engine._getURLOfType(SearchUtils.URL_TYPE.SEARCH) + .templateHost; + for (let innerEngine of this._engines.values()) { + if (!innerEngine.isAppProvided) { + continue; + } + + let innerEngineURL = innerEngine._getURLOfType( + SearchUtils.URL_TYPE.SEARCH + ); + if (innerEngineURL.templateHost == engineHost) { + sendSubmissionURL = true; + break; + } + } + + if (!sendSubmissionURL) { + // ... or well known search domains. + // + // Starts with: www.google., search.aol., yandex. + // or + // Ends with: search.yahoo.com, .ask.com, .bing.com, .startpage.com, baidu.com, duckduckgo.com + const urlTest = /^(?:www\.google\.|search\.aol\.|yandex\.)|(?:search\.yahoo|\.ask|\.bing|\.startpage|\.baidu|duckduckgo)\.com$/; + sendSubmissionURL = urlTest.test(engineHost); + } + } + + if (sendSubmissionURL) { + let uri = engine + ._getURLOfType("text/html") + .getSubmission("", engine, "searchbar").uri; + uri = uri + .mutate() + .setUserPass("") // Avoid reporting a username or password. + .finalize(); + engineData.submissionURL = uri.spec; + } + + return [engine.telemetryId, engineData]; + }, + + async getDefaultEngineInfo() { + let [telemetryId, defaultSearchEngineData] = await this._getEngineInfo( + this.defaultEngine + ); + const result = { + defaultSearchEngine: telemetryId, + defaultSearchEngineData, + }; + + if (this._separatePrivateDefault) { + let [ + privateTelemetryId, + defaultPrivateSearchEngineData, + ] = await this._getEngineInfo(this.defaultPrivateEngine); + result.defaultPrivateSearchEngine = privateTelemetryId; + result.defaultPrivateSearchEngineData = defaultPrivateSearchEngineData; + } + + return result; + }, + + /** + * This map is built lazily after the available search engines change. It + * allows quick parsing of an URL representing a search submission into the + * search engine name and original terms. + * + * The keys are strings containing the domain name and lowercase path of the + * engine submission, for example "www.google.com/search". + * + * The values are objects with these properties: + * { + * engine: The associated nsISearchEngine. + * termsParameterName: Name of the URL parameter containing the search + * terms, for example "q". + * } + */ + _parseSubmissionMap: null, + + _buildParseSubmissionMap() { + this._parseSubmissionMap = new Map(); + + // Used only while building the map, indicates which entries do not refer to + // the main domain of the engine but to an alternate domain, for example + // "www.google.fr" for the "www.google.com" search engine. + let keysOfAlternates = new Set(); + + for (let engine of this._sortedEngines) { + if (engine.hidden) { + continue; + } + + let urlParsingInfo = engine.getURLParsingInfo(); + if (!urlParsingInfo) { + continue; + } + + // Store the same object on each matching map key, as an optimization. + let mapValueForEngine = { + engine, + termsParameterName: urlParsingInfo.termsParameterName, + }; + + let processDomain = (domain, isAlternate) => { + let key = domain + urlParsingInfo.path; + + // Apply the logic for which main domains take priority over alternate + // domains, even if they are found later in the ordered engine list. + let existingEntry = this._parseSubmissionMap.get(key); + if (!existingEntry) { + if (isAlternate) { + keysOfAlternates.add(key); + } + } else if (!isAlternate && keysOfAlternates.has(key)) { + keysOfAlternates.delete(key); + } else { + return; + } + + this._parseSubmissionMap.set(key, mapValueForEngine); + }; + + processDomain(urlParsingInfo.mainDomain, false); + SearchStaticData.getAlternateDomains( + urlParsingInfo.mainDomain + ).forEach(d => processDomain(d, true)); + } + }, + + parseSubmissionURL(url) { + if (!this._initialized) { + // If search is not initialized, do nothing. + // This allows us to use this function early in telemetry. + // The only other consumer of this (places) uses it much later. + return gEmptyParseSubmissionResult; + } + + if (!this._parseSubmissionMap) { + this._buildParseSubmissionMap(); + } + + // Extract the elements of the provided URL first. + let soughtKey, soughtQuery; + try { + let soughtUrl = Services.io.newURI(url).QueryInterface(Ci.nsIURL); + + // Exclude any URL that is not HTTP or HTTPS from the beginning. + if (soughtUrl.scheme != "http" && soughtUrl.scheme != "https") { + return gEmptyParseSubmissionResult; + } + + // Reading these URL properties may fail and raise an exception. + soughtKey = soughtUrl.host + soughtUrl.filePath.toLowerCase(); + soughtQuery = soughtUrl.query; + } catch (ex) { + // Errors while parsing the URL or accessing the properties are not fatal. + return gEmptyParseSubmissionResult; + } + + // Look up the domain and path in the map to identify the search engine. + let mapEntry = this._parseSubmissionMap.get(soughtKey); + if (!mapEntry) { + return gEmptyParseSubmissionResult; + } + + // Extract the search terms from the parameter, for example "caff%C3%A8" + // from the URL "https://www.google.com/search?q=caff%C3%A8&client=firefox". + let encodedTerms = null; + for (let param of soughtQuery.split("&")) { + let equalPos = param.indexOf("="); + if ( + equalPos != -1 && + param.substr(0, equalPos) == mapEntry.termsParameterName + ) { + // This is the parameter we are looking for. + encodedTerms = param.substr(equalPos + 1); + break; + } + } + if (encodedTerms === null) { + return gEmptyParseSubmissionResult; + } + + let length = 0; + let offset = url.indexOf("?") + 1; + let query = url.slice(offset); + // Iterate a second time over the original input string to determine the + // correct search term offset and length in the original encoding. + for (let param of query.split("&")) { + let equalPos = param.indexOf("="); + if ( + equalPos != -1 && + param.substr(0, equalPos) == mapEntry.termsParameterName + ) { + // This is the parameter we are looking for. + offset += equalPos + 1; + length = param.length - equalPos - 1; + break; + } + offset += param.length + 1; + } + + // Decode the terms using the charset defined in the search engine. + let terms; + try { + terms = Services.textToSubURI.UnEscapeAndConvert( + mapEntry.engine.queryCharset, + encodedTerms.replace(/\+/g, " ") + ); + } catch (ex) { + // Decoding errors will cause this match to be ignored. + return gEmptyParseSubmissionResult; + } + + let submission = new ParseSubmissionResult( + mapEntry.engine, + terms, + mapEntry.termsParameterName, + offset, + length + ); + return submission; + }, + + /** + * Gets the WebExtensionPolicy for an add-on. + * + * @param {string} id + * The WebExtension id. + * @returns {WebExtensionPolicy} + */ + async _getExtensionPolicy(id) { + let policy = WebExtensionPolicy.getByID(id); + if (!policy) { + let idPrefix = id.split("@")[0]; + let path = `resource://search-extensions/${idPrefix}/`; + await AddonManager.installBuiltinAddon(path); + policy = WebExtensionPolicy.getByID(id); + } + // On startup the extension may have not finished parsing the + // manifest, wait for that here. + await policy.readyPromise; + return policy; + }, + + // nsIObserver + observe(engine, topic, verb) { + switch (topic) { + case SearchUtils.TOPIC_ENGINE_MODIFIED: + switch (verb) { + case SearchUtils.MODIFIED_TYPE.LOADED: + engine = engine.QueryInterface(Ci.nsISearchEngine); + logConsole.debug("observe: Done installation of ", engine.name); + this._addEngineToStore(engine.wrappedJSObject); + // The addition of the engine to the store always triggers an ADDED + // or a CHANGED notification, that will trigger the task below. + break; + case SearchUtils.MODIFIED_TYPE.ADDED: + case SearchUtils.MODIFIED_TYPE.CHANGED: + case SearchUtils.MODIFIED_TYPE.REMOVED: + // Invalidate the map used to parse URLs to search engines. + this._parseSubmissionMap = null; + break; + } + break; + + case "idle": { + this.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC); + this._queuedIdle = false; + logConsole.debug( + "Reloading engines after idle due to configuration change" + ); + this._maybeReloadEngines().catch(Cu.reportError); + break; + } + + case QUIT_APPLICATION_TOPIC: + this._removeObservers(); + break; + + case TOPIC_LOCALES_CHANGE: + // Locale changed. Re-init. We rely on observers, because we can't + // return this promise to anyone. + + // At the time of writing, when the user does a "Apply and Restart" for + // a new language the preferences code triggers the locales change and + // restart straight after, so we delay the check, which means we should + // be able to avoid the reload on shutdown, and we'll sort it out + // on next startup. + // This also helps to avoid issues with the add-on manager shutting + // down at the same time (see _reInit for more info). + Services.tm.dispatchToMainThread(() => { + if (!Services.startup.shuttingDown) { + this._maybeReloadEngines().catch(Cu.reportError); + } + }); + break; + case Region.REGION_TOPIC: + if (verb == Region.REGION_UPDATED) { + logConsole.debug("Region updated:", Region.home); + this._maybeReloadEngines().catch(Cu.reportError); + } + break; + } + }, + + // nsITimerCallback + notify(timer) { + logConsole.debug("_notify: checking for updates"); + + if ( + !Services.prefs.getBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "update", + true + ) + ) { + return; + } + + // Our timer has expired, but unfortunately, we can't get any data from it. + // Therefore, we need to walk our engine-list, looking for expired engines + var currentTime = Date.now(); + logConsole.debug("currentTime:" + currentTime); + for (let e of this._engines.values()) { + let engine = e.wrappedJSObject; + if (!engine._hasUpdates) { + continue; + } + + var expirTime = engine.getAttr("updateexpir"); + logConsole.debug( + engine.name, + "expirTime:", + expirTime, + "updateURL:", + engine._updateURL, + "iconUpdateURL:", + engine._iconUpdateURL + ); + + var engineExpired = expirTime <= currentTime; + + if (!expirTime || !engineExpired) { + logConsole.debug("skipping engine"); + continue; + } + + logConsole.debug(engine.name, "has expired"); + + engineUpdateService.update(engine); + + // Schedule the next update + engineUpdateService.scheduleNextUpdate(engine); + } // end engine iteration + }, + + _addObservers() { + if (this._observersAdded) { + // There might be a race between synchronous and asynchronous + // initialization for which we try to register the observers twice. + return; + } + this._observersAdded = true; + + Services.obs.addObserver(this, SearchUtils.TOPIC_ENGINE_MODIFIED); + Services.obs.addObserver(this, QUIT_APPLICATION_TOPIC); + Services.obs.addObserver(this, TOPIC_LOCALES_CHANGE); + + this._settings.addObservers(); + + // The current stage of shutdown. Used to help analyze crash + // signatures in case of shutdown timeout. + let shutdownState = { + step: "Not started", + latestError: { + message: undefined, + stack: undefined, + }, + }; + OS.File.profileBeforeChange.addBlocker( + "Search service: shutting down", + () => + (async () => { + // If we are in initialization, then don't attempt to save the settings. + // It is likely that shutdown will have caused the add-on manager to + // stop, which can cause initialization to fail. + // Hence at that stage, we could have broken settings which we don't + // want to write. + // The good news is, that if we don't write the settings here, we'll + // detect the out-of-date settings on next state, and automatically + // rebuild it. + if (!this._initialized) { + logConsole.warn( + "not saving settings on shutdown due to initializing." + ); + return; + } + + try { + await this._settings.shutdown(shutdownState); + } catch (ex) { + // Ensure that error is reported and that it causes tests + // to fail, otherwise ignore it. + Promise.reject(ex); + } + })(), + + () => shutdownState + ); + }, + _observersAdded: false, + + _removeObservers() { + if (this._ignoreListListener) { + IgnoreLists.unsubscribe(this._ignoreListListener); + delete this._ignoreListListener; + } + if (this._queuedIdle) { + this.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC); + this._queuedIdle = false; + } + + this._settings.removeObservers(); + + Services.obs.removeObserver(this, SearchUtils.TOPIC_ENGINE_MODIFIED); + Services.obs.removeObserver(this, QUIT_APPLICATION_TOPIC); + Services.obs.removeObserver(this, TOPIC_LOCALES_CHANGE); + Services.obs.removeObserver(this, Region.REGION_TOPIC); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISearchService", + "nsIObserver", + "nsITimerCallback", + ]), +}; + +var engineUpdateService = { + scheduleNextUpdate(engine) { + var interval = engine._updateInterval || SEARCH_DEFAULT_UPDATE_INTERVAL; + var milliseconds = interval * 86400000; // |interval| is in days + engine.setAttr("updateexpir", Date.now() + milliseconds); + }, + + update(engine) { + engine = engine.wrappedJSObject; + logConsole.debug("update called for", engine._name); + if ( + !Services.prefs.getBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "update", + true + ) || + !engine._hasUpdates + ) { + return; + } + + let testEngine = null; + let updateURL = engine._getURLOfType(SearchUtils.URL_TYPE.OPENSEARCH); + let updateURI = + updateURL && updateURL._hasRelation("self") + ? updateURL.getSubmission("", engine).uri + : SearchUtils.makeURI(engine._updateURL); + if (updateURI) { + if (engine.isAppProvided && !updateURI.schemeIs("https")) { + logConsole.debug("Invalid scheme for default engine update"); + return; + } + + logConsole.debug("updating", engine.name, updateURI.spec); + testEngine = new OpenSearchEngine({ + uri: updateURI, + isAppProvided: false, + }); + testEngine._engineToUpdate = engine; + testEngine._initFromURIAndLoad(updateURI); + } else { + logConsole.debug("invalid updateURI"); + } + + if (engine._iconUpdateURL) { + // If we're updating the engine too, use the new engine object, + // otherwise use the existing engine object. + (testEngine || engine)._setIcon(engine._iconUpdateURL, true); + } + }, +}; + +XPCOMUtils.defineLazyServiceGetter( + SearchService.prototype, + "idleService", + "@mozilla.org/widget/useridleservice;1", + "nsIUserIdleService" +); + +/** + * Handles getting and checking extensions against the allow list. + */ +class SearchDefaultOverrideAllowlistHandler { + /** + * @param {function} listener + * A listener for configuration update changes. + */ + constructor(listener) { + this._remoteConfig = RemoteSettings(SearchUtils.SETTINGS_ALLOWLIST_KEY); + } + + /** + * Determines if a search engine extension can override a default one + * according to the allow list. + * + * @param {object} extension + * The extension object (from add-on manager) that will override the + * app provided search engine. + * @param {string} appProvidedExtensionId + * The id of the search engine that will be overriden. + * @returns {boolean} + * Returns true if the search engine extension may override the app provided + * instance. + */ + async canOverride(extension, appProvidedExtensionId) { + const overrideTable = await this._getAllowlist(); + + let entry = overrideTable.find(e => e.thirdPartyId == extension.id); + if (!entry) { + return false; + } + + if (appProvidedExtensionId != entry.overridesId) { + return false; + } + + let searchProvider = + extension.manifest.chrome_settings_overrides.search_provider; + + return entry.urls.some( + e => + searchProvider.search_url == e.search_url && + searchProvider.search_form == e.search_form && + searchProvider.search_url_get_params == e.search_url_get_params && + searchProvider.search_url_post_params == e.search_url_post_params + ); + } + + /** + * Obtains the configuration from remote settings. This includes + * verifying the signature of the record within the database. + * + * If the signature in the database is invalid, the database will be wiped + * and the stored dump will be used, until the settings next update. + * + * Note that this may cause a network check of the certificate, but that + * should generally be quick. + * + * @returns {array} + * An array of objects in the database, or an empty array if none + * could be obtained. + */ + async _getAllowlist() { + let result = []; + try { + result = await this._remoteConfig.get(); + } catch (ex) { + // Don't throw an error just log it, just continue with no data, and hopefully + // a sync will fix things later on. + Cu.reportError(ex); + } + logConsole.debug("Allow list is:", result); + return result; + } +} + +var EXPORTED_SYMBOLS = ["SearchService"]; diff --git a/toolkit/components/search/SearchSettings.jsm b/toolkit/components/search/SearchSettings.jsm new file mode 100644 index 0000000000..97ec66f8e5 --- /dev/null +++ b/toolkit/components/search/SearchSettings.jsm @@ -0,0 +1,372 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["SearchSettings"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + DeferredTask: "resource://gre/modules/DeferredTask.jsm", + OS: "resource://gre/modules/osfile.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "logConsole", () => { + return console.createInstance({ + prefix: "SearchSettings", + maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +// A text encoder to UTF8, used whenever we commit the settings to disk. +XPCOMUtils.defineLazyGetter(this, "gEncoder", function() { + return new TextEncoder(); +}); + +const SETTINGS_FILENAME = "search.json.mozlz4"; + +/** + * This class manages the saves search settings. + * + * Global settings can be saved and obtained from this class via the + * `*Attribute` methods. + */ +class SearchSettings { + constructor(searchService) { + this._searchService = searchService; + } + + QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver]); + + // Delay for batching invalidation of the JSON settings (ms) + static SETTINGS_INVALIDATION_DELAY = 1000; + + /** + * A reference to the pending DeferredTask, if there is one. + */ + _batchTask = null; + + /** + * The current metadata stored in the settings. This stores: + * - current + * The current user-set default engine. The associated hash is called + * 'hash'. + * - private + * The current user-set private engine. The associated hash is called + * 'privateHash'. + * + * All of the above have associated hash fields to validate the value is set + * by the application. + */ + _metaData = {}; + + /** + * A reference to the search service so that we can save the engines list. + */ + _searchService = null; + + /* + * A copy of the settings so we can persist metadata for engines that + * are not currently active. + */ + _currentSettings = null; + + addObservers() { + Services.obs.addObserver(this, SearchUtils.TOPIC_ENGINE_MODIFIED); + Services.obs.addObserver(this, SearchUtils.TOPIC_SEARCH_SERVICE); + } + + /** + * Cleans up, removing observers. + */ + removeObservers() { + Services.obs.removeObserver(this, SearchUtils.TOPIC_ENGINE_MODIFIED); + Services.obs.removeObserver(this, SearchUtils.TOPIC_SEARCH_SERVICE); + } + + /** + * Reads the settings file. + * + * @param {string} origin + * If this parameter is "test", then the settings will not be written. As + * some tests manipulate the settings directly, we allow turning off writing to + * avoid writing stale settings data. + * @returns {object} + * Returns the settings file data. + */ + async get(origin = "") { + let json; + await this._ensurePendingWritesCompleted(origin); + try { + let settingsFilePath = OS.Path.join( + OS.Constants.Path.profileDir, + SETTINGS_FILENAME + ); + let bytes = await OS.File.read(settingsFilePath, { compression: "lz4" }); + json = JSON.parse(new TextDecoder().decode(bytes)); + if (!json.engines || !json.engines.length) { + throw new Error("no engine in the file"); + } + } catch (ex) { + logConsole.warn("get: No settings file exists, new profile?", ex); + json = {}; + } + if (json.metaData) { + this._metaData = json.metaData; + } + // Versions of gecko older than 82 stored the order flag as a preference. + // This was changed in version 6 of the settings file. + if (json.version < 6 || !("useSavedOrder" in this._metaData)) { + const prefName = SearchUtils.BROWSER_SEARCH_PREF + "useDBForOrder"; + let useSavedOrder = Services.prefs.getBoolPref(prefName, false); + + this.setAttribute("useSavedOrder", useSavedOrder); + + // Clear the old pref so it isn't lying around. + Services.prefs.clearUserPref(prefName); + } + + this._currentSettings = json; + return json; + } + + /** + * Queues writing the settings until after SETTINGS_INVALIDATION_DELAY. If there + * is a currently queued task then it will be restarted. + */ + _delayedWrite() { + if (this._batchTask) { + this._batchTask.disarm(); + } else { + let task = async () => { + if ( + !this._searchService.isInitialized || + this._searchService._reloadingEngines + ) { + // Re-arm the task as we don't want to save potentially incomplete + // information during the middle of (re-)initializing. + this._batchTask.arm(); + return; + } + logConsole.debug("batchTask: Invalidating engine settings"); + await this._write(); + }; + this._batchTask = new DeferredTask( + task, + SearchSettings.SETTINGS_INVALIDATION_DELAY + ); + } + this._batchTask.arm(); + } + + /** + * Ensures any pending writes of the settings are completed. + * + * @param {string} origin + * If this parameter is "test", then the settings will not be written. As + * some tests manipulate the settings directly, we allow turning off writing to + * avoid writing stale settings data. + */ + async _ensurePendingWritesCompleted(origin = "") { + // Before we read the settings file, first make sure all pending tasks are clear. + if (!this._batchTask) { + return; + } + logConsole.debug("finalizing batch task"); + let task = this._batchTask; + this._batchTask = null; + // Tests manipulate the settings directly, so let's not double-write with + // stale settings data here. + if (origin == "test") { + task.disarm(); + } else { + await task.finalize(); + } + } + + /** + * Writes the settings to disk (no delay). + */ + async _write() { + if (this._batchTask) { + this._batchTask.disarm(); + } + + let settings = {}; + + // Allows us to force a settings refresh should the settings format change. + settings.version = SearchUtils.SETTINGS_VERSION; + settings.engines = [...this._searchService._engines.values()]; + settings.metaData = this._metaData; + + // Persist metadata for AppProvided engines even if they aren't currently + // active, this means if they become active again their settings + // will be restored. + if (this._currentSettings?.engines) { + for (let engine of this._currentSettings.engines) { + let included = settings.engines.some(e => e._name == engine._name); + if (engine._isAppProvided && !included) { + settings.engines.push(engine); + } + } + } + + // Update the local copy. + this._currentSettings = settings; + + try { + if (!settings.engines.length) { + throw new Error("cannot write without any engine."); + } + + logConsole.debug("_write: Writing to settings file."); + let path = OS.Path.join(OS.Constants.Path.profileDir, SETTINGS_FILENAME); + let data = gEncoder.encode(JSON.stringify(settings)); + await OS.File.writeAtomic(path, data, { + compression: "lz4", + tmpPath: path + ".tmp", + }); + logConsole.debug("_write: settings file written to disk."); + Services.obs.notifyObservers( + null, + SearchUtils.TOPIC_SEARCH_SERVICE, + "write-settings-to-disk-complete" + ); + } catch (ex) { + logConsole.error("_write: Could not write to settings file:", ex); + } + } + + /** + * Sets an attribute without verification. + * + * @param {string} name + * The name of the attribute to set. + * @param {*} val + * The value to set. + */ + setAttribute(name, val) { + this._metaData[name] = val; + this._delayedWrite(); + } + + /** + * Sets a verified attribute. This will save an additional hash + * value, that can be verified when reading back. + * + * @param {string} name + * The name of the attribute to set. + * @param {*} val + * The value to set. + */ + setVerifiedAttribute(name, val) { + this._metaData[name] = val; + this._metaData[this.getHashName(name)] = SearchUtils.getVerificationHash( + val + ); + this._delayedWrite(); + } + + /** + * Gets an attribute without verification. + * + * @param {string} name + * The name of the attribute to get. + * @returns {*} + * The value of the attribute, or undefined if not known. + */ + getAttribute(name) { + return this._metaData[name] ?? undefined; + } + + /** + * Gets a verified attribute. + * + * @param {string} name + * The name of the attribute to get. + * @returns {*} + * The value of the attribute, or undefined if not known or an empty strings + * if it does not match the verification hash. + */ + getVerifiedAttribute(name) { + let val = this.getAttribute(name); + if ( + val && + this.getAttribute(this.getHashName(name)) != + SearchUtils.getVerificationHash(val) + ) { + logConsole.warn("getVerifiedGlobalAttr, invalid hash for", name); + return undefined; + } + return val; + } + + /** + * Returns the name for the hash for a particular attribute. This is + * necessary because the normal default engine is named `current` with + * its hash as `hash`. All other hashes are in the `<name>Hash` format. + * + * @param {string} name + * The name of the attribute to get the hash name for. + * @returns {string} + * The hash name to use. + */ + getHashName(name) { + if (name == "current") { + return "hash"; + } + return name + "Hash"; + } + + /** + * Handles shutdown; writing the settings if necessary. + * + * @param {object} state + * The shutdownState object that is used to help analyzing the shutdown + * state in case of a crash or shutdown timeout. + */ + async shutdown(state) { + if (!this._batchTask) { + return; + } + state.step = "Finalizing batched task"; + try { + await this._batchTask.finalize(); + state.step = "Batched task finalized"; + } catch (ex) { + state.step = "Batched task failed to finalize"; + + state.latestError.message = "" + ex; + if (ex && typeof ex == "object") { + state.latestError.stack = ex.stack || undefined; + } + } + } + + // nsIObserver + observe(engine, topic, verb) { + switch (topic) { + case SearchUtils.TOPIC_ENGINE_MODIFIED: + switch (verb) { + case SearchUtils.MODIFIED_TYPE.ADDED: + case SearchUtils.MODIFIED_TYPE.CHANGED: + case SearchUtils.MODIFIED_TYPE.REMOVED: + this._delayedWrite(); + break; + } + break; + case SearchUtils.TOPIC_SEARCH_SERVICE: + switch (verb) { + case "init-complete": + case "engines-reloaded": + this._delayedWrite(); + break; + } + break; + } + } +} diff --git a/toolkit/components/search/SearchStaticData.jsm b/toolkit/components/search/SearchStaticData.jsm new file mode 100644 index 0000000000..77a3f965cd --- /dev/null +++ b/toolkit/components/search/SearchStaticData.jsm @@ -0,0 +1,41 @@ +/* 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/. */ + +/* + * This module contains additional data about default search engines that is the + * same across all languages. This information is defined outside of the actual + * search engine definition files, so that localizers don't need to update them + * when a change is made. + * + * This separate module is also easily overridable, in case a hotfix is needed. + * No high-level processing logic is applied here. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["SearchStaticData"]; + +// To update this list of known alternate domains, just cut-and-paste from +// https://www.google.com/supported_domains +const gGoogleDomainsSource = + ".google.com .google.ad .google.ae .google.com.af .google.com.ag .google.com.ai .google.al .google.am .google.co.ao .google.com.ar .google.as .google.at .google.com.au .google.az .google.ba .google.com.bd .google.be .google.bf .google.bg .google.com.bh .google.bi .google.bj .google.com.bn .google.com.bo .google.com.br .google.bs .google.bt .google.co.bw .google.by .google.com.bz .google.ca .google.cd .google.cf .google.cg .google.ch .google.ci .google.co.ck .google.cl .google.cm .google.cn .google.com.co .google.co.cr .google.com.cu .google.cv .google.com.cy .google.cz .google.de .google.dj .google.dk .google.dm .google.com.do .google.dz .google.com.ec .google.ee .google.com.eg .google.es .google.com.et .google.fi .google.com.fj .google.fm .google.fr .google.ga .google.ge .google.gg .google.com.gh .google.com.gi .google.gl .google.gm .google.gp .google.gr .google.com.gt .google.gy .google.com.hk .google.hn .google.hr .google.ht .google.hu .google.co.id .google.ie .google.co.il .google.im .google.co.in .google.iq .google.is .google.it .google.je .google.com.jm .google.jo .google.co.jp .google.co.ke .google.com.kh .google.ki .google.kg .google.co.kr .google.com.kw .google.kz .google.la .google.com.lb .google.li .google.lk .google.co.ls .google.lt .google.lu .google.lv .google.com.ly .google.co.ma .google.md .google.me .google.mg .google.mk .google.ml .google.com.mm .google.mn .google.ms .google.com.mt .google.mu .google.mv .google.mw .google.com.mx .google.com.my .google.co.mz .google.com.na .google.com.nf .google.com.ng .google.com.ni .google.ne .google.nl .google.no .google.com.np .google.nr .google.nu .google.co.nz .google.com.om .google.com.pa .google.com.pe .google.com.pg .google.com.ph .google.com.pk .google.pl .google.pn .google.com.pr .google.ps .google.pt .google.com.py .google.com.qa .google.ro .google.ru .google.rw .google.com.sa .google.com.sb .google.sc .google.se .google.com.sg .google.sh .google.si .google.sk .google.com.sl .google.sn .google.so .google.sm .google.sr .google.st .google.com.sv .google.td .google.tg .google.co.th .google.com.tj .google.tk .google.tl .google.tm .google.tn .google.to .google.com.tr .google.tt .google.com.tw .google.co.tz .google.com.ua .google.co.ug .google.co.uk .google.com.uy .google.co.uz .google.com.vc .google.co.ve .google.vg .google.co.vi .google.com.vn .google.vu .google.ws .google.rs .google.co.za .google.co.zm .google.co.zw .google.cat"; +const gGoogleDomains = gGoogleDomainsSource.split(" ").map(d => "www" + d); + +var SearchStaticData = { + /** + * Returns a list of alternate domains for a given search engine domain. + * + * @param {string} aDomain + * Lowercase host name to look up. For example, if this argument is + * "www.google.com" or "www.google.co.uk", the function returns the + * full list of supported Google domains. + * + * @returns {Array} + * Containing one entry for each alternate host name, or empty array + * if none is known. The returned array should not be modified. + */ + getAlternateDomains(aDomain) { + return !gGoogleDomains.includes(aDomain) ? [] : gGoogleDomains; + }, +}; diff --git a/toolkit/components/search/SearchSuggestionController.jsm b/toolkit/components/search/SearchSuggestionController.jsm new file mode 100644 index 0000000000..4d100a9dec --- /dev/null +++ b/toolkit/components/search/SearchSuggestionController.jsm @@ -0,0 +1,751 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["SearchSuggestionController"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +const DEFAULT_FORM_HISTORY_PARAM = "searchbar-history"; +const HTTP_OK = 200; +const BROWSER_SUGGEST_PREF = "browser.search.suggest.enabled"; +const BROWSER_SUGGEST_PRIVATE_PREF = "browser.search.suggest.enabled.private"; +const REMOTE_TIMEOUT_PREF = "browser.search.suggest.timeout"; +const REMOTE_TIMEOUT_DEFAULT = 500; // maximum time (ms) to wait before giving up on a remote suggestions + +const SEARCH_DATA_TRANSFERRED_SCALAR = "browser.search.data_transferred"; +const SEARCH_TELEMETRY_KEY_PREFIX = "sggt"; +const SEARCH_TELEMETRY_PRIVATE_BROWSING_KEY_SUFFIX = "pb"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "UUIDGenerator", + "@mozilla.org/uuid-generator;1", + "nsIUUIDGenerator" +); + +/** + * Generates an UUID. + * + * @returns {string} + * An UUID string, without leading or trailing braces. + */ +function uuid() { + let uuid = UUIDGenerator.generateUUID().toString(); + return uuid.slice(1, uuid.length - 1); +} + +/** + * Represents a search suggestion. + * TODO: Support other Google tail fields: `a`, `dc`, `i`, `q`, `ansa`, + * `ansb`, `ansc`, `du`. See bug 1626897 comment 2. + */ +class SearchSuggestionEntry { + /** + * Creates an entry. + * @param {string} value + * The suggestion as a full-text string. Suitable for display directly to + * the user. + * @param {string} [matchPrefix] + * Represents the part of a tail suggestion that is already typed. For + * example, Google returns "…" as the match prefix to replace + * "what time is it in" in a tail suggestion for the query + * "what time is it in t". + * @param {string} [tail] + * Represents the suggested part of a tail suggestion. For example, Google + * might return "toronto" as the tail for the query "what time is it in t". + */ + constructor(value, { matchPrefix, tail } = {}) { + this._value = value; + this._matchPrefix = matchPrefix; + this._tail = tail; + } + + /** + * Returns true if `otherEntry` is equivalent to this instance of + * SearchSuggestionEntry. + * @param {SearchSuggestionEntry} otherEntry + * @returns {boolean} + */ + equals(otherEntry) { + return otherEntry.value == this.value; + } + + get value() { + return this._value; + } + + get matchPrefix() { + return this._matchPrefix; + } + + get tail() { + return this._tail; + } + + get tailOffsetIndex() { + if (!this._tail) { + return -1; + } + + let offsetIndex = this._value.lastIndexOf(this._tail); + if (offsetIndex + this._tail.length < this._value.length) { + // We might have a tail suggestion that starts with a word contained in + // the full-text suggestion. e.g. "london sights in l" ... "london". + let lastWordIndex = this._value.lastIndexOf(" "); + if (this._tail.startsWith(this._value.substring(lastWordIndex))) { + offsetIndex = lastWordIndex; + } else { + // Something's gone wrong. Consumers should not show this result. + offsetIndex = -1; + } + } + + return offsetIndex; + } +} + +// Maps each engine name to a unique firstPartyDomain, so that requests to +// different engines are isolated from each other and from normal browsing. +// This is the same for all the controllers. +var gFirstPartyDomains = new Map(); + +/** + * 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 + */ +function SearchSuggestionController(callback = null) { + this._callback = callback; +} + +SearchSuggestionController.prototype = { + /** + * The maximum number of local form history results to return. This limit is + * only enforced if remote results are also returned. + */ + maxLocalResults: 5, + + /** + * The maximum number of remote search engine results to return. + * We'll actually only display at most + * maxRemoteResults - <displayed local results count> remote results. + */ + maxRemoteResults: 10, + + /** + * The additional parameter used when searching form history. + */ + formHistoryParam: DEFAULT_FORM_HISTORY_PARAM, + + // 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 + + /** + * Gets the firstPartyDomains Map, useful for tests. + * @returns {Map} firstPartyDomains mapped by engine names. + */ + get firstPartyDomains() { + return gFirstPartyDomains; + }, + + /** + * 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 {boolean} privateMode - whether the request is being made in the context of private browsing + * @param {nsISearchEngine} engine - search engine for the suggestions. + * @param {int} userContextId - the userContextId of the selected tab. + * @param {boolean} restrictToEngine - whether to restrict local historical + * suggestions to the ones registered under the given engine. + * @param {boolean} dedupeRemoteAndLocal - whether to remove remote + * suggestions that dupe local suggestions + * + * @returns {Promise} resolving to an object with the following contents: + * @returns {array<SearchSuggestionEntry>} results.local + * Contains local search suggestions. + * @returns {array<SearchSuggestionEntry>} results.remote + * Contains remote search suggestions. + */ + fetch( + searchTerm, + privateMode, + engine, + userContextId = 0, + restrictToEngine = false, + dedupeRemoteAndLocal = true + ) { + // 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.getSubmission) { + 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.maxRemoteResults < 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.suggestionsEnabled && + (!privateMode || this.suggestionsInPrivateBrowsingEnabled) && + this.maxRemoteResults && + engine.supportsResponseType(SearchUtils.URL_TYPE.SUGGEST_JSON) + ) { + this._deferredRemoteResult = this._fetchRemote( + searchTerm, + engine, + privateMode, + userContextId + ); + promises.push(this._deferredRemoteResult.promise); + } + + // Local results from form history + if (this.maxLocalResults) { + promises.push( + this._fetchFormHistory( + searchTerm, + restrictToEngine ? engine.name : null + ) + ); + } + + 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( + results => this._dedupeAndReturnResults(results, dedupeRemoteAndLocal), + 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() { + if (this._request) { + this._request.abort(); + } + this._reset(); + }, + + // Private methods + + _fetchFormHistory(searchTerm, source) { + return new Promise(resolve => { + 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), + this.remoteTimeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + + switch (result.searchResult) { + case Ci.nsIAutoCompleteResult.RESULT_SUCCESS: + case Ci.nsIAutoCompleteResult.RESULT_NOMATCH: + if (result.searchString !== this._searchString) { + resolve( + "Unexpected response, this._searchString does not match form history response" + ); + return; + } + let fhEntries = []; + for (let i = 0; i < result.matchCount; ++i) { + fhEntries.push(result.getValueAt(i)); + } + resolve({ + result: fhEntries, + formHistoryResult: result, + }); + break; + case Ci.nsIAutoCompleteResult.RESULT_FAILURE: + case Ci.nsIAutoCompleteResult.RESULT_IGNORED: + resolve("Form History returned RESULT_FAILURE or RESULT_IGNORED"); + break; + } + }, + }; + + let formHistory = Cc[ + "@mozilla.org/autocomplete/search;1?name=form-history" + ].createInstance(Ci.nsIAutoCompleteSearch); + let params = this.formHistoryParam || DEFAULT_FORM_HISTORY_PARAM; + let options = null; + if (source) { + options = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + options.setPropertyAsAUTF8String("source", source); + } + formHistory.startSearch( + searchTerm, + params, + this._formHistoryResult, + acSearchObserver, + options + ); + }); + }, + + /** + * Report bandwidth used by search activities. It only reports when it matches + * search provider information. + * + * @param {string} engineId the name of the search provider. + * @param {boolean} privateMode set to true if this is coming from a private browsing mode request. + */ + _reportBandwidthForEngine(engineId, privateMode) { + if (!this._request || !this._request.channel) { + return; + } + + let channel = ChannelWrapper.get(this._request.channel); + let bytesTransferred = channel.requestSize + channel.responseSize; + if (bytesTransferred == 0) { + return; + } + + let telemetryKey = `${SEARCH_TELEMETRY_KEY_PREFIX}-${engineId}`; + if (privateMode) { + telemetryKey += `-${SEARCH_TELEMETRY_PRIVATE_BROWSING_KEY_SUFFIX}`; + } + + Services.telemetry.keyedScalarAdd( + SEARCH_DATA_TRANSFERRED_SCALAR, + telemetryKey, + bytesTransferred + ); + }, + + /** + * Fetch suggestions from the search engine over the network. + * + * @param {string} searchTerm + * The term being searched for. + * @param {SearchEngine} engine + * The engine to request suggestions from. + * @param {boolean} privateMode + * Set to true if this is coming from a private browsing mode request. + * @param {number} userContextId + * The id of the user container this request was made from. + * @returns {Promise} + * Returns a promise that is resolved when the response is received, or + * rejected if there is an error. + */ + _fetchRemote(searchTerm, engine, privateMode, userContextId) { + let deferredResponse = PromiseUtils.defer(); + this._request = new XMLHttpRequest(); + let submission = engine.getSubmission( + searchTerm, + SearchUtils.URL_TYPE.SUGGEST_JSON + ); + let method = submission.postData ? "POST" : "GET"; + this._request.open(method, submission.uri.spec, true); + // Don't set or store cookies or on-disk cache. + this._request.channel.loadFlags = + Ci.nsIChannel.LOAD_ANONYMOUS | Ci.nsIChannel.INHIBIT_PERSISTENT_CACHING; + // Use a unique first-party domain for each engine, to isolate the + // suggestions requests. + if (!gFirstPartyDomains.has(engine.name)) { + // Use the engine identifier, or an uuid when not available, because the + // domain cannot contain invalid chars and the engine name may not be + // suitable. When using an uuid the firstPartyDomain of the same engine + // will differ across restarts, but that's acceptable for now. + // TODO (Bug 1511339): use a persistent unique identifier per engine. + gFirstPartyDomains.set( + engine.name, + `${engine.identifier || uuid()}.search.suggestions.mozilla` + ); + } + let firstPartyDomain = gFirstPartyDomains.get(engine.name); + + this._request.setOriginAttributes({ + userContextId, + privateBrowsingId: privateMode ? 1 : 0, + firstPartyDomain, + }); + + this._request.mozBackgroundRequest = true; // suppress dialogs and fail silently + + let engineId = engine.identifier || "other"; + + this._request.addEventListener( + "load", + this._onRemoteLoaded.bind(this, deferredResponse, engineId, privateMode) + ); + this._request.addEventListener("error", evt => { + this._reportBandwidthForEngine(engineId, privateMode); + 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 => { + this._reportBandwidthForEngine(engineId, privateMode); + deferredResponse.reject("HTTP request aborted"); + }); + + if (submission.postData) { + this._request.sendInputStream(submission.postData); + } else { + this._request.send(); + } + + return deferredResponse; + }, + + /** + * Called when the request completed successfully (thought the HTTP status could be anything) + * so we can handle the response data. + * + * @param {Promise} deferredResponse + * The promise to resolve when a response is received. + * @param {string} engineId + * The name of the search provider. + * @param {boolean} privateMode + * Set to true if this is coming from a private browsing mode request. + * @private + */ + _onRemoteLoaded(deferredResponse, engineId, privateMode) { + if (!this._request) { + deferredResponse.resolve( + "Got HTTP response after the request was cancelled" + ); + return; + } + + this._reportBandwidthForEngine(engineId, privateMode); + + 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 ( + !Array.isArray(serverResults) || + !serverResults[0] || + this._searchString.localeCompare(serverResults[0], undefined, { + sensitivity: "base", + }) + ) { + // something is wrong here so drop remote results + deferredResponse.resolve( + "Unexpected response, this._searchString does not match remote response" + ); + return; + } + + // Remove the search string from the server results since it is no longer + // needed. + let results = serverResults.slice(1) || []; + deferredResponse.resolve({ result: results }); + }, + + /** + * Called when this._remoteResultTimer fires indicating the remote request took too long. + */ + _onRemoteTimeout() { + 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) + * @param {boolean} dedupeRemoteAndLocal - whether to remove remote + * suggestions that dupe local suggestions + * @returns {object} + */ + _dedupeAndReturnResults(suggestResults, dedupeRemoteAndLocal) { + if (this._searchString === null) { + // _searchString can be null if stop() was called and remote suggestions + // were disabled (stopping if we are fetching remote suggestions will + // cause a promise rejection before we reach _dedupeAndReturnResults). + return null; + } + + let results = { + term: this._searchString, + remote: [], + local: [], + formHistoryResult: null, + }; + + for (let resultData of suggestResults) { + if (typeof result === "string") { + // Failure message + Cu.reportError( + "SearchSuggestionController found an unexpected string value: " + + resultData + ); + } else if (resultData.formHistoryResult) { + // Local results have a formHistoryResult property. + results.formHistoryResult = resultData.formHistoryResult; + if (resultData.result) { + results.local = resultData.result.map( + s => new SearchSuggestionEntry(s) + ); + } + } else if (resultData.result) { + // Remote result + let richSuggestionData = this._getRichSuggestionData(resultData.result); + let fullTextSuggestions = resultData.result[0]; + for (let i = 0; i < fullTextSuggestions.length; ++i) { + results.remote.push( + this._newSearchSuggestionEntry( + fullTextSuggestions[i], + richSuggestionData?.[i] + ) + ); + } + } + } + + // If we have remote results, cap the number of local results + if (results.remote.length) { + results.local = results.local.slice(0, this.maxLocalResults); + } + + // We don't want things to appear in both history and suggestions so remove + // entries from remote results that are already in local. + if (results.remote.length && results.local.length && dedupeRemoteAndLocal) { + for (let i = 0; i < results.local.length; ++i) { + let dupIndex = results.remote.findIndex(e => + e.equals(results.local[i]) + ); + 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 - results.local.length + ); + + if (this._callback) { + this._callback(results); + } + this._reset(); + + return results; + }, + + /** + * Returns rich suggestion data from a remote fetch, if available. + * @param {array} remoteResultData + * The results.remote array returned by SearchSuggestionsController.fetch. + * @returns {array} + * An array of additional rich suggestion data. Each element should + * correspond to the array of text suggestions. + */ + _getRichSuggestionData(remoteResultData) { + if (!remoteResultData || !Array.isArray(remoteResultData)) { + return undefined; + } + + for (let entry of remoteResultData) { + if ( + typeof entry == "object" && + entry.hasOwnProperty("google:suggestdetail") + ) { + let richData = entry["google:suggestdetail"]; + if ( + Array.isArray(richData) && + richData.length == remoteResultData[0].length + ) { + return richData; + } + } + } + return undefined; + }, + + /** + * Given a text suggestion and rich suggestion data, returns a + * SearchSuggestionEntry. + * @param {string} suggestion + * A suggestion string. + * @param {object} richSuggestionData + * Rich suggestion data returned by the engine. In Google's case, this is + * the corresponding entry at "google:suggestdetail". + * @returns {SearchSuggestionEntry} + */ + _newSearchSuggestionEntry(suggestion, richSuggestionData) { + if (richSuggestionData) { + // We have valid rich suggestions. + return new SearchSuggestionEntry(suggestion, { + matchPrefix: richSuggestionData?.mp, + tail: richSuggestionData?.t, + }); + } + // Return a regular suggestion. + return new SearchSuggestionEntry(suggestion); + }, + + _reset() { + this._request = null; + if (this._remoteResultTimer) { + this._remoteResultTimer.cancel(); + this._remoteResultTimer = null; + } + this._deferredRemoteResult = null; + this._searchString = null; + }, +}; + +/** + * Determines whether the given engine offers search suggestions. + * + * @param {nsISearchEngine} engine - The search engine + * @returns {boolean} True if the engine offers suggestions and false otherwise. + */ +SearchSuggestionController.engineOffersSuggestions = function(engine) { + return engine.supportsResponseType(SearchUtils.URL_TYPE.SUGGEST_JSON); +}; + +/** + * The maximum length of a value to be stored in search history. + */ +SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH = 255; + +/** + * The maximum time (ms) to wait before giving up on a remote suggestions. + */ +XPCOMUtils.defineLazyPreferenceGetter( + SearchSuggestionController.prototype, + "remoteTimeout", + REMOTE_TIMEOUT_PREF, + REMOTE_TIMEOUT_DEFAULT +); + +/** + * Whether or not remote suggestions are turned on. + */ +XPCOMUtils.defineLazyPreferenceGetter( + SearchSuggestionController.prototype, + "suggestionsEnabled", + BROWSER_SUGGEST_PREF, + true +); + +/** + * Whether or not remote suggestions are turned on in private browsing mode. + */ +XPCOMUtils.defineLazyPreferenceGetter( + SearchSuggestionController.prototype, + "suggestionsInPrivateBrowsingEnabled", + BROWSER_SUGGEST_PRIVATE_PREF, + false +); diff --git a/toolkit/components/search/SearchSuggestions.jsm b/toolkit/components/search/SearchSuggestions.jsm new file mode 100644 index 0000000000..b7be602d13 --- /dev/null +++ b/toolkit/components/search/SearchSuggestions.jsm @@ -0,0 +1,241 @@ +/* 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/. */ + +const { FormAutoCompleteResult } = ChromeUtils.import( + "resource://gre/modules/nsFormAutoCompleteResult.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter( + 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 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() { + this._init(); +} +SuggestAutoComplete.prototype = { + _init() { + this._suggestionController = new SearchSuggestionController(obj => + this.onResultsReturned(obj) + ); + this._suggestionController.maxLocalResults = this._historyLimit; + }, + + /** + * The object implementing nsIAutoCompleteObserver that we notify when + * we have found results + * @private + */ + _listener: null, + + /** + * Maximum number of history items displayed. This is capped at 7 + * because the primary consumer (Firefox search bar) displays 10 rows + * by default, and so we want to leave some space for suggestions + * to be visible. + */ + _historyLimit: 7, + + /** + * Callback for handling results from SearchSuggestionController.jsm + * + * @param {Array} results + * The array of results that have been returned. + * @private + */ + onResultsReturned(results) { + let finalResults = []; + let finalComments = []; + + // If form history has results, add them to the list. + for (let i = 0; i < results.local.length; ++i) { + finalResults.push(results.local[i].value); + finalComments.push(""); + } + + // If there are remote matches, add them. + if (results.remote.length) { + // "comments" column values for suggestions are empty strings + let comments = new Array(results.remote.length).fill(""); + // now put the history results above the suggestions + // We shouldn't show tail suggestions in their full-text form. + let nonTailEntries = results.remote.filter( + e => !e.matchPrefix && !e.tail + ); + finalResults = finalResults.concat(nonTailEntries.map(e => e.value)); + finalComments = finalComments.concat(comments); + } + + // Notify the FE of our new results + this.onResultsReady( + results.term, + finalResults, + finalComments, + results.formHistoryResult + ); + }, + + /** + * Notifies the front end of new results. + * + * @param {string} searchString + * The user's query string. + * @param {array} results + * An array of results to the search. + * @param {array} comments + * An array of metadata corresponding to the results. + * @param {object} formHistoryResult + * Any previous form history result. + * @private + */ + onResultsReady(searchString, results, comments, formHistoryResult) { + if (this._listener) { + // Create a copy of the results array to use as labels, since + // FormAutoCompleteResult doesn't like being passed the same array + // for both. + let labels = results.slice(); + let result = new FormAutoCompleteResult( + searchString, + Ci.nsIAutoCompleteResult.RESULT_SUCCESS, + 0, + "", + results, + labels, + comments, + formHistoryResult + ); + + this._listener.onSearchResult(this, result); + + // Null out listener to make sure we don't notify it twice + this._listener = null; + } + }, + + /** + * Initiates the search result gathering process. Part of + * nsIAutoCompleteSearch implementation. + * + * @param {string} searchString + * The user's query string. + * @param {string} searchParam + * unused, "an extra parameter"; even though this parameter and the + * next are unused, pass them through in case the form history + * service wants them + * @param {object} previousResult + * unused, a client-cached store of the previous generated resultset + * for faster searching. + * @param {object} listener + * object implementing nsIAutoCompleteObserver which we notify when + * results are ready. + */ + startSearch(searchString, searchParam, previousResult, listener) { + // Don't reuse a previous form history result when it no longer applies. + if (!previousResult) { + this._formHistoryResult = null; + } + + var formHistorySearchParam = searchParam.split("|")[0]; + + // Receive the information about the privacy mode of the window to which + // this search box belongs. The front-end's search.xml bindings passes this + // information in the searchParam parameter. The alternative would have + // been to modify nsIAutoCompleteSearch to add an argument to startSearch + // and patch all of autocomplete to be aware of this, but the searchParam + // argument is already an opaque argument, so this solution is hopefully + // less hackish (although still gross.) + var privacyMode = searchParam.split("|")[1] == "private"; + + // Start search immediately if possible, otherwise once the search + // service is initialized + if (Services.search.isInitialized) { + this._triggerSearch( + searchString, + formHistorySearchParam, + listener, + privacyMode + ); + return; + } + + Services.search + .init() + .then(() => { + this._triggerSearch( + searchString, + formHistorySearchParam, + listener, + privacyMode + ); + }) + .catch(result => + Cu.reportError( + "Could not initialize search service, bailing out: " + result + ) + ); + }, + + /** + * Actual implementation of search. + * + * @param {string} searchString + * The user's query string. + * @param {string} searchParam + * unused + * @param {object} listener + * object implementing nsIAutoCompleteObserver which we notify when + * results are ready. + * @param {boolean} privacyMode + * True if the search was made from a private browsing mode context. + */ + _triggerSearch(searchString, searchParam, listener, privacyMode) { + this._listener = listener; + this._suggestionController.fetch( + searchString, + privacyMode, + Services.search.defaultEngine + ); + }, + + /** + * Ends the search result gathering process. Part of nsIAutoCompleteSearch + * implementation. + */ + stopSearch() { + this._suggestionController.stop(); + }, + + // nsISupports + QueryInterface: ChromeUtils.generateQI([ + "nsIAutoCompleteSearch", + "nsIAutoCompleteObserver", + ]), +}; + +/** + * SearchSuggestAutoComplete is a service implementation that handles suggest + * results specific to web searches. + * @constructor + */ +function SearchSuggestAutoComplete() { + // This calls _init() in the parent class (SuggestAutoComplete) via the + // prototype, below. + this._init(); +} +SearchSuggestAutoComplete.prototype = { + classID: Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}"), + __proto__: SuggestAutoComplete.prototype, + serviceURL: "", +}; + +var EXPORTED_SYMBOLS = ["SearchSuggestAutoComplete"]; diff --git a/toolkit/components/search/SearchUtils.jsm b/toolkit/components/search/SearchUtils.jsm new file mode 100644 index 0000000000..f835dd06e8 --- /dev/null +++ b/toolkit/components/search/SearchUtils.jsm @@ -0,0 +1,318 @@ +/* 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/. */ + +/* eslint no-shadow: error, mozilla/no-aArgs: error */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["SearchUtils"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + OS: "resource://gre/modules/osfile.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "logConsole", () => { + return console.createInstance({ + prefix: "SearchUtils", + maxLogLevel: SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +const BROWSER_SEARCH_PREF = "browser.search."; + +/** + * Load listener + */ +class LoadListener { + _bytes = []; + _callback = null; + _channel = null; + _countRead = 0; + _stream = null; + QueryInterface = ChromeUtils.generateQI([ + Ci.nsIRequestObserver, + Ci.nsIStreamListener, + Ci.nsIChannelEventSink, + Ci.nsIInterfaceRequestor, + Ci.nsIProgressEventSink, + ]); + + constructor(channel, callback) { + this._channel = channel; + this._callback = callback; + } + + // nsIRequestObserver + onStartRequest(request) { + logConsole.debug("loadListener: Starting request:", request.name); + this._stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + } + + onStopRequest(request, statusCode) { + logConsole.debug("loadListener: Stopping request:", request.name); + + var requestFailed = !Components.isSuccessCode(statusCode); + if (!requestFailed && request instanceof Ci.nsIHttpChannel) { + requestFailed = !request.requestSucceeded; + } + + if (requestFailed || this._countRead == 0) { + logConsole.warn("loadListener: request failed!"); + // send null so the callback can deal with the failure + this._bytes = null; + } + this._callback(this._bytes); + this._channel = null; + } + + // nsIStreamListener + onDataAvailable(request, inputStream, offset, count) { + this._stream.setInputStream(inputStream); + + // Get a byte array of the data + this._bytes = this._bytes.concat(this._stream.readByteArray(count)); + this._countRead += count; + } + + // nsIChannelEventSink + asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) { + this._channel = newChannel; + callback.onRedirectVerifyCallback(Cr.NS_OK); + } + + // nsIInterfaceRequestor + getInterface(iid) { + return this.QueryInterface(iid); + } + + // nsIProgressEventSink + onProgress(request, progress, progressMax) {} + onStatus(request, status, statusArg) {} +} + +var SearchUtils = { + BROWSER_SEARCH_PREF, + + SETTINGS_KEY: "search-config", + + /** + * This is the Remote Settings key that we use to get the ignore lists for + * engines. + */ + SETTINGS_IGNORELIST_KEY: "hijack-blocklists", + + /** + * This is the Remote Settings key that we use to get the allow lists for + * overriding the default engines. + */ + SETTINGS_ALLOWLIST_KEY: "search-default-override-allowlist", + + /** + * Topic used for events involving the service itself. + */ + TOPIC_SEARCH_SERVICE: "browser-search-service", + + // See documentation in nsISearchService.idl. + TOPIC_ENGINE_MODIFIED: "browser-search-engine-modified", + MODIFIED_TYPE: { + CHANGED: "engine-changed", + LOADED: "engine-loaded", + REMOVED: "engine-removed", + ADDED: "engine-added", + DEFAULT: "engine-default", + DEFAULT_PRIVATE: "engine-default-private", + }, + + URL_TYPE: { + SUGGEST_JSON: "application/x-suggestions+json", + SEARCH: "text/html", + OPENSEARCH: "application/opensearchdescription+xml", + }, + + ENGINES_URLS: { + "prod-main": + "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/search-config/records", + "prod-preview": + "https://firefox.settings.services.mozilla.com/v1/buckets/main-preview/collections/search-config/records", + "stage-main": + "https://settings.stage.mozaws.net/v1/buckets/main/collections/search-config/records", + "stage-preview": + "https://settings.stage.mozaws.net/v1/buckets/main-preview/collections/search-config/records", + }, + + // The following constants are left undocumented in nsISearchService.idl + // For the moment, they are meant for testing/debugging purposes only. + + // Set an arbitrary cap on the maximum icon size. Without this, large icons can + // cause big delays when loading them at startup. + MAX_ICON_SIZE: 20000, + + DEFAULT_QUERY_CHARSET: "UTF-8", + + // A tag to denote when we are using the "default_locale" of an engine. + DEFAULT_TAG: "default", + + MOZ_PARAM: { + DATE: "moz:date", + DIST_ID: "moz:distributionID", + LOCALE: "moz:locale", + OFFICIAL: "moz:official", + }, + + LoadListener, + + /** + * Notifies watchers of SEARCH_ENGINE_TOPIC about changes to an engine or to + * the state of the search service. + * + * @param {nsISearchEngine} engine + * The engine to which the change applies. + * @param {string} verb + * A verb describing the change. + * + * @see nsISearchService.idl + */ + notifyAction(engine, verb) { + if (Services.search.isInitialized) { + logConsole.debug("NOTIFY: Engine:", engine.name, "Verb:", verb); + Services.obs.notifyObservers(engine, this.TOPIC_ENGINE_MODIFIED, verb); + } + }, + + /** + * Wrapper function for nsIIOService::newURI. + * @param {string} urlSpec + * The URL string from which to create an nsIURI. + * @returns {nsIURI} an nsIURI object, or null if the creation of the URI failed. + */ + makeURI(urlSpec) { + try { + return Services.io.newURI(urlSpec); + } catch (ex) {} + + return null; + }, + + /** + * Wrapper function for nsIIOService::newChannel. + * + * @param {string|nsIURI} url + * The URL string from which to create an nsIChannel. + * @returns {nsIChannel} + * an nsIChannel object, or null if the url is invalid. + */ + makeChannel(url) { + try { + let uri = typeof url == "string" ? Services.io.newURI(url) : url; + return Services.io.newChannelFromURI( + uri, + null /* loadingNode */, + Services.scriptSecurityManager.getSystemPrincipal(), + null /* triggeringPrincipal */, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + } catch (ex) {} + + return null; + }, + + /** + * Tests whether this a partner distribution. + * + * @returns {boolean} + * Whether this is a partner distribution. + */ + isPartnerBuild() { + return SearchUtils.distroID && !SearchUtils.distroID.startsWith("mozilla"); + }, + + /** + * Current settings version. This should be incremented if the format of the + * settings file is modified. + * + * @returns {number} + * The current settings version. + */ + get SETTINGS_VERSION() { + return 6; + }, + + /** + * Sanitizes a name so that it can be used as an engine name. If it cannot be + * sanitized (e.g. no valid characters), then it returns a random name. + * + * @param {string} name + * The name to be sanitized. + * @returns {string} + * The sanitized name. + */ + sanitizeName(name) { + const maxLength = 60; + const minLength = 1; + var result = name.toLowerCase(); + result = result.replace(/\s+/g, "-"); + result = result.replace(/[^-a-z0-9]/g, ""); + + // Use a random name if our input had no valid characters. + if (result.length < minLength) { + result = Math.random() + .toString(36) + .replace(/^.*\./, ""); + } + + // Force max length. + return result.substring(0, maxLength); + }, + + getVerificationHash(name) { + let disclaimer = + "By modifying this file, I agree that I am doing so " + + "only within $appName itself, using official, user-driven search " + + "engine selection processes, and in a way which does not circumvent " + + "user consent. I acknowledge that any attempt to change this file " + + "from outside of $appName is a malicious act, and will be responded " + + "to accordingly."; + + let salt = + OS.Path.basename(OS.Constants.Path.profileDir) + + name + + disclaimer.replace(/\$appName/g, Services.appinfo.name); + + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + + // Data is an array of bytes. + let data = converter.convertToByteArray(salt, {}); + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(hasher.SHA256); + hasher.update(data, data.length); + + return hasher.finish(true); + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + SearchUtils, + "loggingEnabled", + BROWSER_SEARCH_PREF + "log", + false +); + +// Can't use defineLazyPreferenceGetter because we want the value +// from the default branch +XPCOMUtils.defineLazyGetter(SearchUtils, "distroID", () => { + return Services.prefs.getDefaultBranch("distribution.").getCharPref("id", ""); +}); diff --git a/toolkit/components/search/Sidebar.jsm b/toolkit/components/search/Sidebar.jsm new file mode 100644 index 0000000000..a036d616bc --- /dev/null +++ b/toolkit/components/search/Sidebar.jsm @@ -0,0 +1,27 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */ +/* 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/. */ + +function nsSidebar() {} + +nsSidebar.prototype = { + init(window) { + this.window = window; + }, + + // This function implements window.external.AddSearchProvider(), + // for compatibility with older browsers. The function has been deprecated + // and so will not be implemented. + AddSearchProvider(engineURL) {}, + + // This function exists to implement window.external.IsSearchProviderInstalled(), + // for compatibility with other browsers. The function has been deprecated + // and so will not be implemented. + IsSearchProviderInstalled() {}, + + classID: Components.ID("{22117140-9c6e-11d3-aaf1-00805f8a4905}"), + QueryInterface: ChromeUtils.generateQI(["nsIDOMGlobalPropertyInitializer"]), +}; + +var EXPORTED_SYMBOLS = ["nsSidebar"]; diff --git a/toolkit/components/search/components.conf b/toolkit/components/search/components.conf new file mode 100644 index 0000000000..f6b21e01ff --- /dev/null +++ b/toolkit/components/search/components.conf @@ -0,0 +1,35 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +HAVE_SIDEBAR = buildconfig.substs['MOZ_BUILD_APP'] in ('browser', 'mobile/android', 'xulrunner') + +Classes = [ + { + 'js_name': 'search', + 'cid': '{7319788a-fe93-4db3-9f39-818cf08f4256}', + 'contract_ids': ['@mozilla.org/browser/search-service;1'], + 'interfaces': ['nsISearchService'], + 'jsm': 'resource://gre/modules/SearchService.jsm', + 'constructor': 'SearchService', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}', + 'contract_ids': ['@mozilla.org/autocomplete/search;1?name=search-autocomplete'], + 'jsm': 'resource://gre/modules/SearchSuggestions.jsm', + 'constructor': 'SearchSuggestAutoComplete', + }, +] + +if HAVE_SIDEBAR: + Classes += [ + { + 'cid': '{22117140-9c6e-11d3-aaf1-00805f8a4905}', + 'contract_ids': ['@mozilla.org/sidebar;1'], + 'jsm': 'resource://gre/modules/Sidebar.jsm', + 'constructor': 'nsSidebar', + }, + ] diff --git a/toolkit/components/search/docs/DefaultSearchEngines.rst b/toolkit/components/search/docs/DefaultSearchEngines.rst new file mode 100644 index 0000000000..5668646648 --- /dev/null +++ b/toolkit/components/search/docs/DefaultSearchEngines.rst @@ -0,0 +1,66 @@ +====================== +Default Search Engines +====================== + +The search service specifies default search engines via the `configuration +schema`_. + +Changing Defaults +================= + +The default engine may change when: + +* The user has the default engine set and the configuration for the locale/region + changes. +* The user has the default engine set and their locale/region changes to one + which has a different default. +* The user chooses to set a different engine via preferences. +* The user installs an add-on which sets its default as one of the application + provided engines. +* The user installs an add-on which supplies a different engine and allows the + different engine to be set as default. + +Add-ons and App-provided Engines +-------------------------------- + +An add-on may set the name of the search provider in the manifest.json to be +the name of an app-provided engine. In this case: + +* If the add-on is a non-authorised partner, then we set the user's default + engine to be the name of the app-provided engine. +* If the add-on is from an authorised partner, then we set the users' default + engine to be the same as the app-provided engine, and we allow the + app-provided urls to be overridden with those of the add-on. + +If the specified engine is already default, then the add-on does +not override the app-provided engine, and it's settings are ignored and no +new engine is added. + +The list of authorised add-ons is stored in `remote settings`_ in the +`search-default-override-allowlist bucket`_. The list +includes records containing: + +* Third-party Add-on Id: The identifier of the third party add-on which will + override the app provided one. +* Add-on Id to Override: The identifier of the app-provided add-on to be + overridden. +* a list of the url / params that are authorised to be replaced. + +When an authorised add-on overrides the default, we record the add-on's id +with the app-provided engine in the ``overriddenBy`` field. This is used +when the engine is loaded on startup to known that it should load the parameters +from that add-on. + +The ``overriddenBy`` annotation may be removed when: + +* The associated authorised add-on is removed, disabled or can no longer be found. +* The user changes their default to another engine. + +If the ``overriddenBy`` annotation is present, but the add-on is not authorised, +then the annotation will be maintained in case the add-on is later re-authorised. +For example, a url is updated, but the update is performed before the allow list +is updated. + +.. _configuration schema: SearchConfigurationSchema.html +.. _remote settings: /services/common/services/RemoteSettings.html +.. _search-default-override-allowlist bucket: https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/search-default-override-allowlist/records diff --git a/toolkit/components/search/docs/SearchConfigurationSchema.rst b/toolkit/components/search/docs/SearchConfigurationSchema.rst new file mode 100644 index 0000000000..ce28f0f2ec --- /dev/null +++ b/toolkit/components/search/docs/SearchConfigurationSchema.rst @@ -0,0 +1,515 @@ +=========================== +Search Configuration Schema +=========================== + +This document outlines the details of the schema and how the various sub-parts +interact. For the full fields and descriptions, please see the `schema itself`_. + +.. note:: + In the examples, only relevant properties are displayed. + +Overview +======== + +The configuration is a JSON blob which is object with a `data` property which +is an array of engines: + +.. code-block:: js + + { + data: [ + { + // engine 1 details + }, + { + // engine 2 details + } + ] + } + +Engine Objects +============== + +An engine's details are located in the properties of the object associated with it. +An engine that is deployed globally could be listed simply as: + +.. code-block:: js + + { + "default": "no", + "telemetryId": "engine1-telem", + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + } + }] + } + +The ``appliesTo`` section is an array of objects. At least one object is required +to specify which regions/locales the engine is included within. If an +``appliesTo`` object lists additional attributes then these will override any +attributes at the top-level. + +For example, a more complex engine definition may be available only to users +located specific regions or with certain locales. For example: + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "region": "us" + }, + "webExtension": { + "id": "web-us@ext" + } + }, { + "included": { + "region": "gb" + }, + "webExtension": { + "id": "web-gb@ext" + } + }] + } + +In this case users identified as being in the US region would use the WebExtension +with identifier ``web-us@ext``. GB region users would get +``web-gb@ext``, and all other users would get ``web@ext``. + +Special Attributes +================== + +$USER_LOCALE +------------ + +If a ``webExtension.locales`` property contains an element with the value +``"$USER_LOCALE"`` then the special value will be replaced in the +configuration object with the users locale. For example: + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "locales": { + "matches": ["us", "gb"] + }, + "webExtension": { + "locales": ["$USER_LOCALE"], + } + } + }] + } + +Will report either ``[us]`` or ``[gb]`` as the ``webExtension.locales`` +depending on the user's locale. + +"default" +--------- + +You can specify ``"default"`` as a region in the configuration if +the engine is to be included when we do not know the user's region. + +"override" +---------- + +The ``"override"`` field can be set to true if you want a section to +only override otherwise included engines. ``"override"`` will only work for +sections which apply to distributions or experiments. The experiment case was +added in Firefox 81. + +Example: + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + // Complicated and lengthy inclusion rules + }, { + "override": true, + "application": { "distributions": ["mydistrocode"]}, + "params": { + "searchUrlGetParams": [ + { "name": "custom", "value": "foobar" } + ] + } + }] + } + +Application Scoping +=================== + +An engine configuration may be scoped to a particular application. + +Name +---- + +One or more application names may be specified. Currently the only application +type supported is ``firefox``. If an application name is specified, then it +must be matched for the section to apply. If there are no application names +specified, then the section will match any consumer of the configuration. + +In the following example, ``web@ext`` would be included on any consumer +of the configuration, but ``web1@ext`` would only be included on Firefox desktop. + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "application": { + "name": [] + } + } + ]} + }, + { + "webExtension": { + "id": "web1@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "application": { + "name": ["firefox"] + } + } + ]} + } + +Channel +------- + +One or more channels may be specified in an array to restrict a configuration +to just those channels. The current known channels are: + + - default: Self-builds of Firefox, or possibly some self-distributed versions. + - nightly: Firefox Nightly builds. + - aurora: Firefox Developer Edition + - beta: Firefox Beta + - release: The main Firefox release channel. + - esr: The ESR Channel. This will also match versions of Firefox where the + displayed version number includes ``esr``. We do this to include Linux + distributions and other manual builds of ESR. + +In the following example, ``web@ext`` would be set as default on the default +channel only, whereas ``web1@ext`` would be set as default on release and esr +channels. + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "default": "yes", + "application": { + "channel": ["default"] + } + } + ]} + }, + { + "webExtension": { + "id": "web1@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "default": "yes", + "application": { + "channel": ["release", "esr"] + } + } + ]} + } + +Distributions +------------- + +Distributions may be specified to be included or excluded in an ``appliesTo`` +section. The ``distributions`` field in the ``application`` section is an array +of distribution identifiers. The identifiers match those supplied by the +``distribution.id`` preference. + +In the following, ``web@ext`` would be included in only the ``cake`` +distribution. ``web1@ext`` would be excluded from the ``apples`` distribution +but included in the main desktop application, and all other distributions. + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "application": { + "distributions": ["cake"] + } + } + ]} + }, + { + "webExtension": { + "id": "web1@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "application": { + "excludedDistributions": ["apples"] + } + } + ]} + } + +Version +------- + +Minimum and Maximum versions may be specified to restrict a configuration to +specific ranges. These may be open-ended. Version comparison is performed +using `the version comparator`_. + +Note: comparison against ``maxVersion`` is a less-than comparison. The +``maxVersion`` won't be matched directly. + +In the following example, ``web@ext`` would be included for any version after +72.0a1, whereas ``web1@ext`` would be included only between 68.0a1 and 71.x +version. + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "application": { + "minVersion": "72.0a1" + } + } + ]} + }, + { + "webExtension": { + "id": "web1@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + "default": "yes", + "application": { + "minVersion": "68.0a1" + "maxVersion": "72.0a1" + } + } + ]} + } + +Experiments +=========== + +We can run experiments by giving sections within ``appliesTo`` a +``experiment`` value, the Search Service can then optionally pass in a +matching ``experiment`` value to match those sections. + +Sections which have a ``experiment`` will not be used unless a matching +``experiment`` has been passed in, for example: + +.. code-block:: js + + { + "webExtension": { + "id": "web@ext" + }, + "appliesTo": [{ + "included": { + "everywhere": true + }, + "experiment": "nov-16", + "webExtension": { + "id": "web-experimental@ext" + } + }, { + "included": { + "everywhere": true + }, + "webExtension": { + "id": "web-gb@ext" + } + }] + } + +Engine Defaults +=============== + +An engine may be specified as the default for one of two purposes: + +#. normal browsing mode, +#. private browsing mode. + +If there is no engine specified for private browsing mode for a particular region/locale +pair, then the normal mode engine is used. + +If the instance of the application does not support a separate private browsing mode engine, +then it will only use the normal mode engine. + +An engine may or may not be default for particular regions/locales. The ``default`` +property is a tri-state value with states of ``yes``, ``yes-if-no-other`` and +``no``. Here's an example of how they apply: + +.. code-block:: js + + { + "webExtension": { + "id": "engine1@ext" + }, + "appliesTo": [{ + "included": { + "region": "us" + }, + "default": "yes" + }, { + "excluded": { + "region": "us" + }, + "default": "yes-if-no-other" + }] + }, + { + "webExtension": { + "id": "engine2@ext" + }, + "appliesTo": [{ + "included": { + "region": "gb" + }, + "default": "yes" + }] + }, + "webExtension": { + "id": "engine3@ext" + }, + "default": "no" + "appliesTo": [{ + "included": { + "everywhere": true + }, + }] + }, + { + "webExtension": { + "id": "engine4@ext" + }, + "defaultPrivate": "yes", + "appliesTo": [{ + "included": { + "region": "fr" + } + }] + } + +In this example, for normal mode: + + - engine1@ext is default in the US region, and all other regions except for GB + - engine2@ext is default in only the GB region + - engine3@ext and engine4 are never default anywhere + +In private browsing mode: + + - engine1@ext is default in the US region, and all other regions execpt for GB and FR + - engine2@ext is default in only the GB region + - engine3@ext is never default anywhere + - engine4@ext is default in the FR region. + +Engine Ordering +=============== + +The ``orderHint`` field indicates the suggested ordering of an engine relative to +other engines when displayed to the user, unless the user has customized their +ordering. + +The default ordering of engines is based on a combination of if the engine is +default, and the ``orderHint`` fields. The ordering is structured as follows: + +#. Default engine in normal mode +#. Default engine in private browsing mode (if different from the normal mode engine) +#. Other engines in order from the highest ``orderHint`` to the lowest. + +Example: + +.. code-block:: js + + { + "webExtension": { + "id": "engine1@ext" + }, + "orderHint": 2000, + "default": "no", + }, + { + "webExtension": { + "id": "engine2@ext" + }, + "orderHint": 1000, + "default": "yes" + }, + { + "webExtension": { + "id": "engine3@ext" + }, + "orderHint": 500, + "default": "no" + } + +This would result in the order: ``engine2@ext, engine1@ext, engine3@ext``. + +Region Params +============= + +The ``regionParams`` field allows us to override query parameters used based on the users current Region without having to reload the engine list which is based on the users home Region. + +Example: + +.. code-block:: js + + { + "webExtension": { + "id": "engine1@ext" + }, + "params": { + "searchUrlGetParams": [{ "name": "param", "value": "default" }], + }, + "regionParams": { + "US": [{ "name": "param", "value": "custom" }] + } + }, + +Will send ``param=custom`` whenever we detect the user is in US. + +.. _schema itself: https://searchfox.org/mozilla-central/source/toolkit/components/search/schema/ +.. _the version comparator: https://developer.mozilla.org/en-US/docs/Mozilla/Toolkit_version_format diff --git a/toolkit/components/search/docs/SearchEngineConfiguration.rst b/toolkit/components/search/docs/SearchEngineConfiguration.rst new file mode 100644 index 0000000000..e9041affb8 --- /dev/null +++ b/toolkit/components/search/docs/SearchEngineConfiguration.rst @@ -0,0 +1,72 @@ +=========================== +Search Engine Configuration +=========================== + +The search engine configuration is a mapping that is used to determine the +list of search engines for each user. The mapping is primarily based on the +user's region and locale. + +Configuration Management +======================== + +The application stores a dump of the configuration that is used for first +initialisation. Subsequent updates to the configuration are either updates to the +static dump, or they may be served via remote servers. + +The mechanism of delivering the settings dumps to the Search Service is +`Remote Settings`_ + +Remote settings +--------------- + +The remote settings bucket for the search engine configuration list is +``search-config``. The version that is currently being delivered +to clients can be `viewed live`_. + +Configuration Schema +==================== + +The configuration format is defined via a `JSON schema`_. The search engine +configuration schema is `stored in mozilla-central`_ and is uploaded to the +Remote Settings server at convenient times after it changes. + +An outline of the schema may be found on the `Search Configuration Schema`_ page. + +Updating Search Engine WebExtensions +==================================== + +Updates for application provided search engine WebExtensions are provided via +`Normandy`_. + +It is likely that updates for search engine WebExtensions will be +received separately to configuration updates which may or may not be directly +related. As a result several situations may occur: + + - The updated WebExtension is for an app-provided engine already in-use by + the user. + + - In this case, the search service will apply the changes to the + app-provided engine's data. + + - A WebExtension addition/update is for an app-provided engine that is not + in-use by the user, or not in the configuration. + + - In this case, the search service will ignore the WebExtension. + - If the configuration (search or user) is updated later and the + new engine is added, then the Search Service will start to use the + new engine. + + - A configuration update is received that needs a WebExtension that is + not found locally. + + - In this case, the search service will ignore the missing engine and + continue without it. + - When the WebExtension is delivered, the search engine will then be + installed and added. + +.. _Remote Settings: /services/common/services/RemoteSettings.html +.. _JSON schema: https://json-schema.org/ +.. _stored in mozilla-central: https://searchfox.org/mozilla-central/source/toolkit/components/search/schema/ +.. _Search Configuration Schema: SearchConfigurationSchema.html +.. _viewed live: https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/search-config/records +.. _Normandy: /toolkit/components/normandy/normandy/services.html diff --git a/toolkit/components/search/docs/Telemetry.rst b/toolkit/components/search/docs/Telemetry.rst new file mode 100644 index 0000000000..70f5271ed1 --- /dev/null +++ b/toolkit/components/search/docs/Telemetry.rst @@ -0,0 +1,23 @@ +========= +Telemetry +========= + +Scalars +------- + +browser.searchinit.init_result_status_code + Records the search service initialization code on startup. This is typically + one of the error values in https://searchfox.org/mozilla-central/source/xpcom/base/ErrorList.py + +Keyed Scalars +------------- + +browser.searchinit.engine_invalid_webextension + Records the WebExtension ID of a search engine where the saved search engine + settings do not match the WebExtension. + + The keys are the WebExtension IDs. The values are integers: + + 1. Associated WebExtension is not installed. + 2. Associated WebExtension is disabled. + 3. The submission URL of the associated WebExtension is different to that of the saved settings. diff --git a/toolkit/components/search/docs/index.rst b/toolkit/components/search/docs/index.rst new file mode 100644 index 0000000000..188f020ebf --- /dev/null +++ b/toolkit/components/search/docs/index.rst @@ -0,0 +1,28 @@ +============== +Search Service +============== + +This is documentation for the Search Service. + +Definitions +=========== + +* Application-provided engine (aka app-provided): This is an engine that is + provided by the application to the user as part of the configurations for the + user's locale/region. +* Default engine: This is the engine that is the one used by default when + doing searches from the address bar, search bar and other places. This may be + a default application provided engine or a user selected engine. +* Default private engine: Same as for the default engine, but this is used by + default when in private browsing mode. + +Contents +======== + +.. toctree:: + :maxdepth: 2 + + SearchEngineConfiguration + SearchConfigurationSchema + DefaultSearchEngines + Telemetry diff --git a/toolkit/components/search/moz.build b/toolkit/components/search/moz.build new file mode 100644 index 0000000000..a7bfa63997 --- /dev/null +++ b/toolkit/components/search/moz.build @@ -0,0 +1,50 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +SPHINX_TREES["/toolkit/search"] = "docs" + +XPCSHELL_TESTS_MANIFESTS += [ + "tests/xpcshell/searchconfigs/xpcshell.ini", + "tests/xpcshell/xpcshell.ini", +] + +XPIDL_SOURCES += [ + "nsISearchService.idl", +] + +XPIDL_MODULE = "toolkit_search" + +if CONFIG["MOZ_BUILD_APP"] in ["browser", "mobile/android", "xulrunner"]: + EXTRA_JS_MODULES += [ + "Sidebar.jsm", + ] + +EXTRA_JS_MODULES += [ + "OpenSearchEngine.jsm", + "SearchEngine.jsm", + "SearchEngineSelector.jsm", + "SearchService.jsm", + "SearchSettings.jsm", + "SearchStaticData.jsm", + "SearchSuggestionController.jsm", + "SearchSuggestions.jsm", + "SearchUtils.jsm", +] + +EXTRA_COMPONENTS += [ + "toolkitsearch.manifest", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +TESTING_JS_MODULES += [ + "tests/SearchTestUtils.jsm", +] + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Search") diff --git a/toolkit/components/search/nsISearchService.idl b/toolkit/components/search/nsISearchService.idl new file mode 100644 index 0000000000..7a291d238f --- /dev/null +++ b/toolkit/components/search/nsISearchService.idl @@ -0,0 +1,508 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIURI; +interface nsIInputStream; + +[scriptable, uuid(5799251f-5b55-4df7-a9e7-0c27812c469a)] +interface nsISearchSubmission : nsISupports +{ + /** + * The POST data associated with a search submission, wrapped in a MIME + * input stream. May be null. + */ + readonly attribute nsIInputStream postData; + + /** + * The URI to submit a search to. + */ + readonly attribute nsIURI uri; +}; + +[scriptable, uuid(620bd920-0491-48c8-99a8-d6047e64802d)] +interface nsISearchEngine : nsISupports +{ + /** + * Gets a nsISearchSubmission object that contains information about what to + * send to the search engine, including the URI and postData, if applicable. + * + * @param data + * Data to add to the submission object. + * i.e. the search terms. + * + * @param responseType [optional] + * The MIME type that we'd like to receive in response + * to this submission. If null, will default to "text/html". + * + * @param purpose [optional] + * A string meant to indicate the context of the search request. This + * allows the search service to provide a different nsISearchSubmission + * depending on e.g. where the search is triggered in the UI. + * + * @returns A nsISearchSubmission object that contains information about what + * to send to the search engine. If no submission can be + * obtained for the given responseType, returns null. + */ + nsISearchSubmission getSubmission(in AString data, + [optional] in AString responseType, + [optional] in AString purpose); + + /** + * Returns the name of the parameter used for the search terms for a submission + * URL of type `SearchUtils.URL_TYPE.SEARCH`. + * + * @returns A string which is the name of the parameter, or empty string + * if no parameter cannot be found or is not supported (e.g. POST). + */ + readonly attribute AString searchUrlQueryParamName; + + /** + * Returns the public suffix for the submission URL of type + * `SearchUtils.URL_TYPE.SEARCH`. + * + * @returns A string which is a known public suffix, or empty string + * if one cannot be found. + */ + readonly attribute AString searchUrlPublicSuffix; + + /** + * Determines whether the engine can return responses in the given + * MIME type. Returns true if the engine spec has a URL with the + * given responseType, false otherwise. + * + * @param responseType + * The MIME type to check for + */ + boolean supportsResponseType(in AString responseType); + + /** + * Returns a string with the URL to an engine's icon matching both width and + * height. Returns null if icon with specified dimensions is not found. + * + * @param width + * Width of the requested icon. + * @param height + * Height of the requested icon. + */ + AString getIconURLBySize(in long width, in long height); + + /** + * Gets an array of all available icons. Each entry is an object with + * width, height and url properties. width and height are numeric and + * represent the icon's dimensions. url is a string with the URL for + * the icon. + */ + jsval getIcons(); + + /** + * Opens a speculative connection to the engine's search URI + * (and suggest URI, if different) to reduce request latency + * + * @param options + * An object that must contain the following fields: + * {window} the content window for the window performing the search + * {originAttributes} the originAttributes for performing the search + * + * @throws NS_ERROR_INVALID_ARG if options is omitted or lacks required + * elemeents + */ + void speculativeConnect(in jsval options); + + /** + * An optional shortcut alias for the engine. + * When non-null, this is a unique identifier. + */ + attribute AString alias; + + /** + * An array of aliases including the user defined alias and + * ones specified by the webextensions keywords field. + */ + readonly attribute Array<AString> aliases; + + /** + * A text description describing the engine. + */ + readonly attribute AString description; + + /** + * Whether the engine should be hidden from the user. + */ + attribute boolean hidden; + + /** + * A nsIURI corresponding to the engine's icon, stored locally. May be null. + */ + readonly attribute nsIURI iconURI; + + /** + * The display name of the search engine. This is a unique identifier. + */ + readonly attribute AString name; + + /** + * A URL string pointing to the engine's search form. + */ + readonly attribute AString searchForm; + + /** + * A boolean to indicate if we should send an attribution request to Mozilla's + * server. + */ + readonly attribute boolean sendAttributionRequest; + + /** + * The identifier to use for this engine when submitting to telemetry. + */ + readonly attribute AString telemetryId; + + /** + * An optional unique identifier for this search engine within the context of + * the distribution, as provided by the distributing entity. + */ + readonly attribute AString identifier; + + /** + * Whether or not this extension is provided by the application, e.g. it is + * in the list of configured search engines, or provided by a distribution + * set-up. + */ + readonly attribute boolean isAppProvided; + + /** + * Gets a string representing the hostname from which search results for a + * given responseType are returned. This can be specified as an url attribute + * in the engine description file, but will default to host from the <Url>'s + * template otherwise. + * + * @param responseType [optional] + * The MIME type to get resultDomain for. Defaults to "text/html". + * + * @return the resultDomain for the given responseType. + */ + AString getResultDomain([optional] in AString responseType); +}; + +[scriptable, uuid(0dc93e51-a7bf-4a16-862d-4b3469ff6206)] +interface nsISearchParseSubmissionResult : nsISupports +{ + /** + * The search engine associated with the URL passed in to + * nsISearchEngine::parseSubmissionURL, or null if the URL does not represent + * a search submission. + */ + readonly attribute nsISearchEngine engine; + + /** + * String containing the sought terms. This can be an empty string in case no + * terms were specified or the URL does not represent a search submission. + */ + readonly attribute AString terms; + + /** + * The name of the query parameter used by `engine` for queries. E.g. "q". + */ + readonly attribute AString termsParameterName; + + /** + * The offset of the string |terms| in the URL passed in to + * nsISearchEngine::parseSubmissionURL, or -1 if the URL does not represent + * a search submission. + */ + readonly attribute long termsOffset; + + /** + * The length of the |terms| in the original encoding of the URL passed in to + * nsISearchEngine::parseSubmissionURL. If the search term in the original + * URL is encoded then this will be bigger than |terms.length|. + */ + readonly attribute long termsLength; +}; + +[scriptable, uuid(0301834b-2630-440e-8b98-db8dc55f34b9)] +interface nsISearchService : nsISupports +{ + const unsigned long ERROR_DOWNLOAD_FAILURE = 0x1; + const unsigned long ERROR_DUPLICATE_ENGINE = 0x2; + const unsigned long ERROR_ENGINE_CORRUPTED = 0x3; + + /** + * Start asynchronous initialization. + * + * The promise is resolved once initialization is complete, which may be + * immediately, if initialization has already been completed by some previous + * call to this method. + * This method should only be called when you need or want to wait for the + * full initialization of the search service. + */ + Promise init(); + + /** + * Determine whether initialization has been completed. + * + * Clients of the service can use this attribute to quickly determine whether + * initialization is complete, and decide to trigger some immediate treatment, + * to launch asynchronous initialization or to bailout. + * + * Note that this attribute does not indicate that initialization has succeeded. + * + * @return |true| if the search service is now initialized, |false| if + * initialization has not been triggered yet. + */ + readonly attribute bool isInitialized; + + /** + * Checks if WebExtension Search Engines are valid and up-to-date. + */ + Promise checkWebExtensionEngines(); + + /** + * Resets the default engine to its original value. + */ + Promise resetToOriginalDefaultEngine(); + + /** + * Adds a new Open Search engine from the file at the supplied URI. + * + * @param engineURL + * The URL to the search engine's description file. + * + * @param iconURL + * A URL string to an icon file to be used as the search engine's + * icon. This value may be overridden by an icon specified in the + * engine description file. + * + * @throws NS_ERROR_FAILURE if the description file cannot be successfully + * loaded. + */ + Promise addOpenSearchEngine(in AString engineURL, in AString iconURL); + + /** + * Adds a new search engine for enterprises. + * + * @param details + * An object that simulates the WebExtension manifest structure: + * + * {iconURL} Optional + * {description} Optional + * {chrome_settings_overrides} This should contain a {search_provider} + * structure that mimics the WebExtension + * manifest structure. + */ + Promise addPolicyEngine(in jsval details); + + /** + * Adds a new search engine defined by the user. + * + * @param name + * The name of the engine. + * @param url + * The url of the engine with %s denoting where to + * replace the search term. + * @param alias [optional] + * The alias to refer to the engine. + */ + Promise addUserEngine(in AString name, + in AString url, + [optional] in AString alias); + + /** + * Test-only function for adding an engine. This should be considered obsolete, + * and will be replaced soon (bug 1649186). + */ + Promise addEngineWithDetails(in AString name, in jsval details); + + /** + * Adds search providers to the search service. If the search + * service is configured to load multiple locales for the extension, + * it may load more than one search engine. If called directly + * ensure the extension has been initialised. + * + * @param extension + * The extension to load from. + * @returns Promise that resolves when finished. + */ + Promise addEnginesFromExtension(in jsval extension); + + /** + * Un-hides all engines in the set of engines returned by getAppProvidedEngines. + */ + void restoreDefaultEngines(); + + /** + * Returns an engine with the specified alias. + * + * @param alias + * The search engine's alias. + * @returns The corresponding nsISearchEngine object, or null if it doesn't + * exist. + */ + Promise getEngineByAlias(in AString alias); + + /** + * Returns an engine with the specified name. + * + * @param aEngineName + * The name of the engine. + * @returns The corresponding nsISearchEngine object, or null if it doesn't + * exist. + */ + nsISearchEngine getEngineByName(in AString aEngineName); + + /** + * Returns an array of all installed search engines. + * The array is sorted either to the user requirements or the default order. + * + * @returns an array of nsISearchEngine objects. + */ + Promise getEngines(); + + /** + * Returns an array of all installed search engines whose hidden attribute is + * false. + * The array is sorted either to the user requirements or the default order. + * + * @returns an array of nsISearchEngine objects. + */ + Promise getVisibleEngines(); + + /** + * Returns an array of all default search engines. This includes all loaded + * engines that aren't in the user's profile directory. + * The array is sorted to the default order. + * + * @returns an array of nsISearchEngine objects. + */ + Promise getAppProvidedEngines(); + + /** + * Returns an array of search engines installed by a given extension. + * + * @returns an array of nsISearchEngine objects. + */ + Promise getEnginesByExtensionID(in AString extensionID); + + /** + * Moves a visible search engine. + * + * @param engine + * The engine to move. + * @param newIndex + * The engine's new index in the set of visible engines. + * + * @throws NS_ERROR_FAILURE if newIndex is out of bounds, or if engine is + * hidden. + */ + Promise moveEngine(in nsISearchEngine engine, in long newIndex); + + /** + * Removes the search engine. If the search engine is installed in a global + * location, this will just hide the engine. If the engine is in the user's + * profile directory, it will be removed from disk. + * + * @param engine + * The engine to remove. + */ + Promise removeEngine(in nsISearchEngine engine); + + /** + * Notify nsSearchService that an extension has been removed. Removes any + * engines that are associated with that extension. + * + * @param id + * The id of the extension. + */ + Promise removeWebExtensionEngine(in AString id); + + /** + * The original Engine object that is the default for this region, + * ignoring changes the user may have subsequently made. + */ + readonly attribute nsISearchEngine originalDefaultEngine; + + /** + * The original Engine object that is the default for this region when in + * private browsing mode, ignoring changes the user may have subsequently made. + */ + readonly attribute nsISearchEngine originalPrivateDefaultEngine; + + /** + * The currently active search engine. + * Unless the application doesn't ship any search plugin, this should never + * be null. If the currently active engine is removed, this attribute will + * fallback first to the original default engine if it's not hidden, then to + * the first visible engine, and as a last resort it will unhide the original + * default engine. + */ + attribute nsISearchEngine defaultEngine; + + Promise getDefault(); + Promise setDefault(in nsISearchEngine engine); + + /** + * The currently active search engine for private browsing mode. + * @see defaultEngine. + */ + attribute nsISearchEngine defaultPrivateEngine; + + Promise getDefaultPrivate(); + Promise setDefaultPrivate(in nsISearchEngine engine); + + /** + * Allows the add-on manager to discover if a WebExtension based search engine + * may change the default to an application provided search engine. + * If that WebExtension is on the allow list, then it will override the + * built-in engine's urls and parameters. + * + * @param extension + * The extension to load from. + * @returns An object with two booleans: + * - canChangeToAppProvided: indicates if the WebExtension engine may + * set the named engine as default e.g. it is application provided. + * - canInstallEngine: indicates if the WebExtension engine may be + * installed, e.g. it is not an app-provided engine. + */ + Promise maybeSetAndOverrideDefault(in jsval extension); + + /** + * Gets a representation of the default engine in an anonymized JSON + * string suitable for recording in the Telemetry environment. + * + * @return {object} result + * contains anonymized info about the default engine(s). + * @return {string} result.defaultSearchEngine + * contains the telemetry id of the default engine. + * @return {object} result.defaultSearchEngineData + * contains information about the default engine: + * name, loadPath, original submissionURL + * @return {string} [result.defaultPrivateSearchEngine] + * only returned if the preference for having a separate engine in private + * mode is turned on. + * contains the telemetry id of the default engine for private browsing mode. + * @return {object} [result.defaultPrivateSearchEngineData] + * only returned if the preference for having a separate engine in private + * mode is turned on. + * contains information about the default engine for private browsing mode: + * name, loadPath, original submissionURL + */ + Promise getDefaultEngineInfo(); + + /** + * Determines if the provided URL represents results from a search engine, and + * provides details about the match. + * + * The lookup mechanism checks whether the domain name and path of the + * provided HTTP or HTTPS URL matches one of the known values for the visible + * search engines. The match does not depend on which of the schemes is used. + * The expected URI parameter for the search terms must exist in the query + * string, but other parameters are ignored. + * + * @param url + * String containing the URL to parse, for example + * "https://www.google.com/search?q=terms". + */ + nsISearchParseSubmissionResult parseSubmissionURL(in AString url); +}; diff --git a/toolkit/components/search/schema/Readme.txt b/toolkit/components/search/schema/Readme.txt new file mode 100644 index 0000000000..14fffb5c10 --- /dev/null +++ b/toolkit/components/search/schema/Readme.txt @@ -0,0 +1,7 @@ +The schemas in this directory are the primary source for the schemas they represent. + +They are uploaded to the RemoteSettings server to validate new configurations. + +Any changes should be validated by the Search team. + +See the documentation for more information: https://firefox-source-docs.mozilla.org/ diff --git a/toolkit/components/search/schema/search-default-override-allowlist-schema.json b/toolkit/components/search/schema/search-default-override-allowlist-schema.json new file mode 100644 index 0000000000..64d7970b32 --- /dev/null +++ b/toolkit/components/search/schema/search-default-override-allowlist-schema.json @@ -0,0 +1,55 @@ +{ + "type": "object", + "required": [ + "thirdPartyId", + "overridesId", + "urls" + ], + "properties": { + "thirdPartyId": { + "type": "string", + "title": "Third-party Add-on Id", + "description": "The identifier of the third party add-on which will override the app provided one. Should be of the format example@foo", + "pattern": "^[a-zA-Z0-9-._]*@[a-zA-Z0-9-._]*$" + }, + "overridesId": { + "type": "string", + "title": "Add-on Id to Override", + "description": "The identifier of the app-provided add-on to be overriden. Should be of the format example@search.mozilla.org", + "pattern": "^[a-zA-Z0-9-._]*@search.mozilla.org$" + }, + "urls": { + "type": "array", + "title": "URLs", + "description": "An array of URL sets which must be matched (with the add-on's manifest settings) to allow the override", + "items": { + "type": "object", + "required": [ + "search_url" + ], + "properties": { + "search_url": { + "type": "string", + "title": "search_url", + "description": "The main search url" + }, + "search_url_get_params": { + "type": "string", + "title": "search_url_get_params", + "description": "Any get parameters" + }, + "search_url_post_params": { + "type": "string", + "title": "search_url_post_params", + "description": "Any post parameters" + }, + "search_form": { + "type": "string", + "title": "search_form", + "description": "The search form url" + } + } + } + } + } +} diff --git a/toolkit/components/search/schema/search-default-override-allowlist-ui-schema.json b/toolkit/components/search/schema/search-default-override-allowlist-ui-schema.json new file mode 100644 index 0000000000..7c22e0be38 --- /dev/null +++ b/toolkit/components/search/schema/search-default-override-allowlist-ui-schema.json @@ -0,0 +1,7 @@ +{ + "ui:order": [ + "thirdPartyId", + "overridesId", + "urls" + ] +} diff --git a/toolkit/components/search/schema/search-engine-config-schema.json b/toolkit/components/search/schema/search-engine-config-schema.json new file mode 100644 index 0000000000..af8aa2a1f9 --- /dev/null +++ b/toolkit/components/search/schema/search-engine-config-schema.json @@ -0,0 +1,433 @@ +{ + "type": "object", + "required": [ + "webExtension" + ], + "properties": { + "default": { + "$ref": "#/definitions/default" + }, + "defaultPrivate": { + "$ref": "#/definitions/defaultPrivate" + }, + "orderHint": { + "$ref": "#/definitions/orderHint" + }, + "appliesTo": { + "type": "array", + "title": "Applies To", + "description": "This section defines the region/locales/application information for where a search engine is available, and any specifics for that region/locale/application. If there are no entries in the list, it is considered to be included everywhere", + "items": { + "$ref": "#/definitions/appliesToSection" + } + }, + "sendAttributionRequest": { + "$ref": "#/definitions/sendAttributionRequest" + }, + "telemetryId": { + "type": "string", + "title": "Telemetry Id", + "description": "The telemetry Id as used for some of SEARCH_COUNTS telemetry." + }, + "webExtension": { + "$ref": "#/definitions/webExtension" + }, + "params": { + "$ref": "#/definitions/params" + }, + "extraParams": { + "$ref": "#/definitions/extraParams" + }, + "regionParams": { + "$ref": "#/definitions/regionParams" + } + }, + "definitions": { + "application": { + "type": "object", + "title": "Application Details", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The application this applies to (default/not specified is everywhere)", + "enum": [ + "", + "firefox" + ] + }, + "channel": { + "type": "array", + "title": "Channel", + "description": "Which channel this belongs to (not set = everywhere). For ESR this is also keyed from the display version.", + "items": { + "type": "string", + "enum": [ + "default", + "nightly", + "aurora", + "beta", + "release", + "esr" + ] + }, + "uniqueItems": true + }, + "distributions": { + "type": "array", + "title": "Distributions", + "description": "Which distributions this applies to.", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "excludedDistributions": { + "type": "array", + "title": "Excluded Distributions", + "description": "Which distributions this does not apply to.", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "minVersion": { + "type": "string", + "title": "Minimum Version", + "description": "The minimum version this applies to" + }, + "maxVersion": { + "type": "string", + "title": "Maxium Version", + "description": "The maximum version this applies to (less-than comparison)" + } + } + }, + "default": { + "type": "string", + "title": "Default Status", + "description": "Whether or not this engine should be default.", + "enum": [ + "yes", + "yes-if-no-other", + "no" + ] + }, + "defaultPrivate": { + "type": "string", + "title": "Default Status (PBM)", + "description": "Whether or not this engine should be default in private browsing mode.", + "enum": [ + "yes", + "yes-if-no-other", + "no" + ] + }, + "extraParams": { + "type": "array", + "title": "Extra Parameters", + "description": "Extra parameters for the search engine (aka MozParams)", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the parameter that will be used in the search query" + }, + "condition": { + "type": "string", + "title": "Condition", + "description": "The type of parameter (pref or purpose)", + "enum": [ + "pref", + "purpose" + ] + }, + "purpose": { + "type": "string", + "title": "Purpose", + "description": "The search purpose that triggers this parameter being appended.", + "enum": [ + "searchbar", + "keyword", + "contextmenu", + "homepage", + "newtab" + ] + }, + "value": { + "type": "string", + "title": "Value", + "description": "If this is a purpose type, the value is used as the value of the parameter in the query" + }, + "pref": { + "type": "string", + "title": "Preference name", + "description": "The preference name to get the value from (i.e. browser.search.param.<preference name>)." + } + } + } + }, + "orderHint": { + "type": "number", + "title": "Order Hint", + "description": "A hint to the display order (higher is a higer rank)" + }, + "sapCodes": { + "type": "object", + "title": "Search Access Point Codes", + "description": "Codes for the search access points.", + "properties": { + "any": { + "type": "string", + "title": "Any", + "description": "SAP code that is used for all access points (overrides the others)." + }, + "contextMenu": { + "type": "string", + "title": "Context Menu", + "description": "SAP code for searches from the context menu." + }, + "homePage": { + "type": "string", + "title": "Home page", + "description": "SAP code for searches from the home page." + }, + "keyword": { + "type": "string", + "title": "Keyword", + "description": "SAP code for searches via keywords." + }, + "newTab": { + "type": "string", + "title": "New Tab", + "description": "SAP code for searches from the new tab page." + }, + "searchBar": { + "type": "string", + "title": "Search Bar", + "description": "SAP code for searches from the search bar." + } + } + }, + "searchUrlCodes": { + "type": "array", + "title": "Codes", + "description": "A array of objects - map of parameter name to the parameter value.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the parameter that will be used in the query" + }, + "value": { + "type": "string", + "title": "Value", + "description": "The value of parameter (pref or purpose)" + } + } + } + }, + "params": { + "type": "object", + "title": "Parameters", + "description": "Various parameters for the search engines", + "properties": { + "searchUrlGetParams": { + "title": "Search URL GET Parameters", + "description": "Extra parameters for search URLs (e.g. 'pc=foo').", + "$ref": "#/definitions/searchUrlCodes" + }, + "suggestUrlGetParams": { + "title": "Suggestion URL GET Parameters", + "description": "Extra parameters for search suggestion URLs (e.g. 'pc=foo').", + "$ref": "#/definitions/searchUrlCodes" + }, + "searchUrlPostParams": { + "title": "Search URL POST Parameters", + "description": "Extra parameters for search URLs (e.g. 'pc=foo').", + "$ref": "#/definitions/searchUrlCodes" + }, + "suggestUrlPostParams": { + "title": "Suggestion URL POST Parameters", + "description": "Extra parameters for search suggestion URLs (e.g. 'pc=foo').", + "$ref": "#/definitions/searchUrlCodes" + }, + "sapCodes": { + "$ref": "#/definitions/sapCodes" + } + } + }, + "regionParams": { + "type": "object", + "title": "Region Parameters", + "description": "A map of regions to an array of parameter overrides", + "patternProperties": { + "^([a-z][a-z]|default)$": { + "type": "array", + "title": "Parameter Overrides", + "description": "A array of objects - map of parameter name to the parameter value.", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Name of the parameter to override" + }, + "value": { + "type": "string", + "title": "Value", + "description": "The value to override with" + } + } + } + } + } + }, + "sendAttributionRequest": { + "type": "boolean", + "title": "Send Attribution Request", + "description": "Indicates if we should send an attribution request to Mozilla's server." + }, + "telemetryId": { + "type": "string", + "title": "Telemetry Id", + "description": "The telemetry Id as used for some of SEARCH_COUNTS telemetry." + }, + "webExtension": { + "type": "object", + "title": "WebExtension", + "properties": { + "id": { + "type": "string", + "title": "WebExtension Id", + "description": "The identifier (local part) of the associated WebExtension should be of the format example@search.mozilla.org", + "pattern": "^[a-zA-Z0-9-._]*@search.mozilla.org$" + }, + "locale": { + "type": "string", + "title": "WebExtension Locale", + "description": "Overrides the WebExtension locales and specifies to use a particular one. Ideally this should only be used when really necessary, otherwise considered deprecated." + } + } + }, + "regionDetails": { + "type": "array", + "title": "Regions", + "description": "Two-letter region codes.", + "items": { + "type": "string", + "pattern": "^([a-z][a-z]|default)$", + "minLength": 2, + "maxLength": 7 + } + }, + "localeDetails": { + "type": "object", + "title": "Locales", + "description": "Locale codes.", + "properties": { + "matches": { + "type": "array", + "title": "Matches exactly the codes", + "items": { + "type": "string", + "pattern": "^([a-z]{2,3}(\\-[a-zA-Z]{2,})?(-macos)?|default)$", + "minLength": 2 + } + }, + "startsWith": { + "type": "array", + "title": "Matches any code starting with", + "items": { + "type": "string", + "pattern": "^[a-z]{2,3}$", + "minLength": 2, + "maxLength": 3 + } + } + } + }, + "included": { + "type": "object", + "title": "Included Locations", + "description": "The locations to which this section applies. Note: Regions and Locales are 'and'ed together.", + "properties": { + "everywhere": { + "type": "boolean", + "title": "Everywhere", + "description": "Set to true to signify that this is included everywhere." + }, + "regions": { + "$ref" : "#/definitions/regionDetails" + }, + "locales": { + "$ref": "#/definitions/localeDetails" + } + } + }, + "excluded": { + "type": "object", + "title": "Excluded Locations", + "description": "The locations to which this section applies. Note: Regions and Locales are 'and'ed together.", + "properties": { + "regions": { + "$ref" : "#/definitions/regionDetails" + }, + "locales": { + "$ref": "#/definitions/localeDetails" + } + } + }, + "appliesToSection": { + "type": "object", + "properties": { + "default": { + "$ref": "#/definitions/default" + }, + "defaultPrivate": { + "$ref": "#/definitions/defaultPrivate" + }, + "orderHint": { + "$ref": "#/definitions/orderHint" + }, + "included": { + "$ref": "#/definitions/included" + }, + "excluded": { + "$ref": "#/definitions/excluded" + }, + "application": { + "$ref": "#/definitions/application" + }, + "webextension": { + "$ref": "#/definitions/webExtension" + }, + "sendAttributionRequest": { + "$ref": "#/definitions/sendAttributionRequest" + }, + "telemetryId": { + "$ref": "#/definitions/telemetryId" + }, + "params": { + "$ref": "#/definitions/params" + }, + "regionParams": { + "$ref": "#/definitions/regionParams" + }, + "extraParams": { + "$ref": "#/definitions/extraParams" + }, + "experiment": { + "type": "string", + "title": "Experiment", + "description": "The experiment this section is associated with, if blank it is associated with any configuration." + } + } + } + } +} diff --git a/toolkit/components/search/schema/search-engine-config-ui-schema.json b/toolkit/components/search/schema/search-engine-config-ui-schema.json new file mode 100644 index 0000000000..27e99424d8 --- /dev/null +++ b/toolkit/components/search/schema/search-engine-config-ui-schema.json @@ -0,0 +1,14 @@ +{ + "ui:order": [ + "webExtension", + "default", + "defaultPrivate", + "orderHint", + "sendAttributionRequest", + "telemetryId", + "params", + "extraParams", + "regionParams", + "appliesTo" + ] +} diff --git a/toolkit/components/search/tests/SearchTestUtils.jsm b/toolkit/components/search/tests/SearchTestUtils.jsm new file mode 100644 index 0000000000..68b1afecb3 --- /dev/null +++ b/toolkit/components/search/tests/SearchTestUtils.jsm @@ -0,0 +1,352 @@ +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AddonTestUtils: "resource://testing-common/AddonTestUtils.jsm", + ExtensionTestUtils: "resource://testing-common/ExtensionXPCShellUtils.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + sinon: "resource://testing-common/Sinon.jsm", +}); + +Cu.importGlobalProperties(["fetch"]); + +var EXPORTED_SYMBOLS = ["SearchTestUtils"]; + +var gTestScope; + +var SearchTestUtils = Object.freeze({ + init(testScope) { + gTestScope = testScope; + // This handles xpcshell-tests. + if (!("ExtensionTestUtils" in gTestScope)) { + gTestScope.ExtensionTestUtils = ExtensionTestUtils; + } + }, + + /** + * Adds an OpenSearch based engine to the search service. It will remove + * the engine at the end of the test. + * + * @param {string} url The URL of the engine to add. + * @param {Function} registerCleanupFunction Pass the registerCleanupFunction + * from the test's scope. + * @returns {Promise} Returns a promise that is resolved with the new engine + * or rejected if it fails. + */ + async promiseNewSearchEngine(url) { + let engine = await Services.search.addOpenSearchEngine(url, ""); + gTestScope.registerCleanupFunction(async () => + Services.search.removeEngine(engine) + ); + return engine; + }, + + /** + * Returns a promise that is resolved when an observer notification from the + * search service fires with the specified data. + * + * @param {*} expectedData + * The value the observer notification sends that causes us to resolve + * the promise. + * @param {string} topic + * The notification topic to observe. Defaults to 'browser-search-service'. + * @returns {Promise} + * Returns a promise that is resolved with the subject of the + * topic once the topic with the data has been observed. + */ + promiseSearchNotification(expectedData, topic = "browser-search-service") { + return new Promise(resolve => { + Services.obs.addObserver(function observer(aSubject, aTopic, aData) { + if (aData != expectedData) { + return; + } + + Services.obs.removeObserver(observer, topic); + // Let the stack unwind. + Services.tm.dispatchToMainThread(() => resolve(aSubject)); + }, topic); + }); + }, + + /** + * Load engines from test data located in particular folders. + * + * @param {string} [folder] + * The folder name to use. + * @param {string} [subFolder] + * The subfolder to use, if any. + * @param {array} [config] + * An array which contains the configuration to set. + * @returns {object} + * An object that is a sinon stub for the configuration getter. + */ + async useTestEngines(folder = "data", subFolder = null, config = null) { + let url = `resource://test/${folder}/`; + if (subFolder) { + url += `${subFolder}/`; + } + let resProt = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProt.setSubstitution("search-extensions", Services.io.newURI(url)); + + const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + if (config) { + return sinon.stub(settings, "get").returns(config); + } + + let response = await fetch(`resource://search-extensions/engines.json`); + let json = await response.json(); + return sinon.stub(settings, "get").returns(json.data); + }, + + async useMochitestEngines(testDir) { + // Replace the path we load search engines from with + // the path to our test data. + let resProt = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + let originalSubstitution = resProt.getSubstitution("search-extensions"); + resProt.setSubstitution( + "search-extensions", + Services.io.newURI("file://" + testDir.path) + ); + gTestScope.registerCleanupFunction(() => { + resProt.setSubstitution("search-extensions", originalSubstitution); + }); + }, + + /** + * Convert a list of engine configurations into engine objects. + * + * @param {Array} engineConfigurations + **/ + async searchConfigToEngines(engineConfigurations) { + let engines = []; + for (let config of engineConfigurations) { + let engine = await Services.search.wrappedJSObject.makeEngineFromConfig( + config + ); + engines.push(engine); + } + return engines; + }, + + /** + * Provides various setup for xpcshell-tests installing WebExtensions. Should + * be called from the global scope of the test. + * + * @param {object} scope + * The global scope of the test being run. + * @param {*} usePrivilegedSignatures + * How to sign created addons. + */ + initXPCShellAddonManager(scope, usePrivilegedSignatures = false) { + let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION; + Services.prefs.setIntPref("extensions.enabledScopes", scopes); + Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false + ); + gTestScope.ExtensionTestUtils.init(scope); + AddonTestUtils.usePrivilegedSignatures = usePrivilegedSignatures; + AddonTestUtils.overrideCertDB(); + }, + + /** + * Add a search engine as a WebExtension. + * + * Note: If you are in xpcshell-tests, then you should call + * `initXPCShellAddonManager` before calling this. + * + * @param {object} [options] + * @see createEngineManifest + */ + async installSearchExtension(options = {}) { + options.id = (options.id ?? "example") + "@tests.mozilla.org"; + let extensionInfo = { + useAddonManager: "permanent", + manifest: this.createEngineManifest(options), + }; + + let extension = gTestScope.ExtensionTestUtils.loadExtension(extensionInfo); + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + return extension; + }, + + /** + * Install a search engine as a system extension to simulate + * Normandy updates. For xpcshell-tests only. + * + * Note: If you are in xpcshell-tests, then you should call + * `initXPCShellAddonManager` before calling this. + * + * @param {object} [options] + * @see createEngineManifest + */ + async installSystemSearchExtension(options = {}) { + options.id = (options.id ?? "example") + "@search.mozilla.org"; + let xpi = await AddonTestUtils.createTempWebExtensionFile({ + manifest: this.createEngineManifest(options), + background() { + // eslint-disable-next-line no-undef + browser.test.sendMessage("started"); + }, + }); + let wrapper = gTestScope.ExtensionTestUtils.expectExtension(options.id); + + const install = await AddonManager.getInstallForURL(`file://${xpi.path}`, { + useSystemLocation: true, + }); + + install.install(); + + await wrapper.awaitStartup(); + await wrapper.awaitMessage("started"); + + return wrapper; + }, + + /** + * Create a search engine extension manifest. + * + * @param {object} [options] + * @param {string} [options.id] + * The id to use for the WebExtension. + * @param {string} [options.name] + * The display name to use for the WebExtension. + * @param {string} [options.version] + * The version to use for the WebExtension. + * @param {string} [options.keyword] + * The keyword to use for the WebExtension. + * @param {string} [options.encoding] + * The encoding to use for the WebExtension. + * @param {string} [options.search_url] + * The search URL to use for the WebExtension. + * @param {string} [options.search_url_get_params] + * The search URL parameters to use for the WebExtension. + * @param {string} [options.suggest_url] + * The suggestion URL to use for the WebExtension. + * @param {string} [options.suggest_url] + * The suggestion URL parameters to use for the WebExtension. + * @returns {object} + * The generated manifest. + */ + createEngineManifest(options = {}) { + options.id = options.id ?? "example@tests.mozilla.org"; + options.name = options.name ?? "Example"; + options.version = options.version ?? "1.0"; + let manifest = { + version: options.version, + applications: { + gecko: { + id: options.id, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: options.name, + search_url: options.search_url ?? "https://example.com/", + search_url_get_params: + options.search_url_get_params ?? "?q={searchTerms}", + }, + }, + }; + if (options.encoding) { + manifest.chrome_settings_overrides.search_provider.encoding = + options.encoding; + } + if (options.keyword) { + manifest.chrome_settings_overrides.search_provider.keyword = + options.keyword; + } + if (options.suggest_url) { + manifest.chrome_settings_overrides.search_provider.suggest_url = + options.suggest_url; + } + if (options.suggest_url) { + manifest.chrome_settings_overrides.search_provider.suggest_url_get_params = + options.suggest_url_get_params; + } + return manifest; + }, + + /** + * A mock idleService that allows us to simulate RemoteSettings + * configuration updates. + */ + idleService: { + _observers: new Set(), + + _reset() { + this._observers.clear(); + }, + + _fireObservers(state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 19999, + + addIdleObserver(observer, time) { + this._observers.add(observer); + }, + + removeIdleObserver(observer, time) { + this._observers.delete(observer); + }, + }, + + /** + * Register the mock idleSerice. + * + * @param {Fun} registerCleanupFunction + */ + useMockIdleService() { + let fakeIdleService = MockRegistrar.register( + "@mozilla.org/widget/useridleservice;1", + SearchTestUtils.idleService + ); + gTestScope.registerCleanupFunction(() => { + MockRegistrar.unregister(fakeIdleService); + }); + }, + + /** + * Simulates an update to the RemoteSettings configuration. + * + * @param {object} [config] + * The new configuration. + */ + async updateRemoteSettingsConfig(config) { + if (!config) { + let settings = RemoteSettings(SearchUtils.SETTINGS_KEY); + config = await settings.get(); + } + const reloadObserved = SearchTestUtils.promiseSearchNotification( + "engines-reloaded" + ); + await RemoteSettings(SearchUtils.SETTINGS_KEY).emit("sync", { + data: { current: config }, + }); + + this.idleService._fireObservers("idle"); + await reloadObserved; + }, +}); diff --git a/toolkit/components/search/tests/xpcshell/data/bigIcon.ico b/toolkit/components/search/tests/xpcshell/data/bigIcon.ico Binary files differnew file mode 100644 index 0000000000..f22522411d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/bigIcon.ico diff --git a/toolkit/components/search/tests/xpcshell/data/engine-app/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-app/manifest.json new file mode 100644 index 0000000000..ea82c79e1d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-app/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "TestEngineApp", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-app@search.mozilla.org" + } + }, + "hidden": true, + "description": "A test search engine installed in the application directory", + "chrome_settings_overrides": { + "search_provider": { + "name": "TestEngineApp", + "search_url": "https://localhost/", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-chromeicon.xml b/toolkit/components/search/tests/xpcshell/data/engine-chromeicon.xml new file mode 100644 index 0000000000..856732c6d6 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-chromeicon.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>engine-chromeicon</ShortName> +<Image width="16" height="16">chrome://branding/content/icon16.png</Image> +<Image width="32" height="32">chrome://branding/content/icon32.png</Image> +<Url type="text/html" method="GET" template="http://www.google.com/search"> + <Param name="q" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/toolkit/components/search/tests/xpcshell/data/engine-chromeicon/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-chromeicon/manifest.json new file mode 100644 index 0000000000..9b2b88ea27 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-chromeicon/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "engine-chromeicon", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-chromeicon@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "engine-chromeicon", + "search_url": "https://www.google.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-fr.xml b/toolkit/components/search/tests/xpcshell/data/engine-fr.xml new file mode 100644 index 0000000000..4bb4426a12 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-fr.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>Test search engine (fr)</ShortName>
+<Description>A test search engine (based on Google search for a different locale)</Description>
+<InputEncoding>ISO-8859-1</InputEncoding>
+<Url type="text/html" method="GET" template="http://www.google.fr/search">
+ <Param name="q" value="{searchTerms}"/>
+ <Param name="ie" value="iso-8859-1"/>
+ <Param name="oe" value="iso-8859-1"/>
+</Url>
+<SearchForm>http://www.google.fr/</SearchForm>
+</SearchPlugin>
diff --git a/toolkit/components/search/tests/xpcshell/data/engine-fr/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-fr/manifest.json new file mode 100644 index 0000000000..fa68f8d53a --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-fr/manifest.json @@ -0,0 +1,32 @@ +{ + "name": "Test search engine (fr)", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-fr@search.mozilla.org" + } + }, + "hidden": true, + "description": "A test search engine (based on Google search for a different locale)", + "chrome_settings_overrides": { + "search_provider": { + "name": "Test search engine (fr)", + "search_url": "https://www.google.fr/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "ie", + "value": "iso-8859-1" + }, + { + "name": "oe", + "value": "iso-8859-1" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-override/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-override/manifest.json new file mode 100644 index 0000000000..422a7693a4 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-override/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "bug645970", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-override@search.mozilla.org" + } + }, + "hidden": true, + "description": "override", + "chrome_settings_overrides": { + "search_provider": { + "name": "bug645970", + "search_url": "https://searchtest.local", + "params": [ + { + "name": "search", + "value": "{searchTerms}" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-pref/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-pref/manifest.json new file mode 100644 index 0000000000..a20e6904be --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-pref/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "engine-pref", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-pref@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "engine-pref", + "search_url": "https://www.google.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "code", + "condition": "pref", + "pref": "code" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-rel-searchform-purpose/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-rel-searchform-purpose/manifest.json new file mode 100644 index 0000000000..7c5547eef6 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-rel-searchform-purpose/manifest.json @@ -0,0 +1,41 @@ +{ + "name": "engine-rel-searchform-purpose", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-rel-searchform-purpose@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "engine-rel-searchform-purpose", + "search_url": "https://www.google.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "contextmenu", + "value": "rcs" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "keyword", + "value": "fflb" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "searchbar", + "value": "sb" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-reordered/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-reordered/manifest.json new file mode 100644 index 0000000000..9f5cf93561 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-reordered/manifest.json @@ -0,0 +1,40 @@ +{ + "name": "Test search engine (Reordered)", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-reordered@search.mozilla.org" + } + }, + "hidden": true, + "description": "A test search engine (based on Google search)", + "icons": { + "16": "favicon.ico" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "Test search engine (Reordered)", + "search_url": "https://www.google.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "contextmenu", + "value": "rcs" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "keyword", + "value": "fflb" + } + ], + "suggest_url": "https://suggestqueries.google.com/complete/search?output=firefox&client=firefox&hl={moz:locale}&q={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-resourceicon.xml b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon.xml new file mode 100644 index 0000000000..32861c34ea --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>engine-resourceicon</ShortName> +<Image width="16" height="16">resource://search-extensions/icon16.png</Image> +<Image width="32" height="32">resource://search-extensions/icon32.png</Image> +<Url type="text/html" method="GET" template="http://www.google.com/search"> + <Param name="q" value="{searchTerms}"/> +</Url> +</SearchPlugin> diff --git a/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/en/messages.json b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/en/messages.json new file mode 100644 index 0000000000..1cc3f68ee1 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/en/messages.json @@ -0,0 +1,8 @@ +{ + "extensionName": { + "message": "engine-resourceicon" + }, + "searchUrl": { + "message": "https://www.google.com/search" + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/gd/messages.json b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/gd/messages.json new file mode 100644 index 0000000000..3c02e6a2af --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/gd/messages.json @@ -0,0 +1,8 @@ +{ + "extensionName": { + "message": "engine-resourceicon-gd" + }, + "searchUrl": { + "message": "https://www.google.com/search" + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/manifest.json new file mode 100644 index 0000000000..2fdfc8e190 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-resourceicon/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "engine-resourceicon", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-resourceicon@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "default_locale": "en", + "chrome_settings_overrides": { + "search_provider": { + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/en/messages.json b/toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/en/messages.json new file mode 100644 index 0000000000..ee808e7a62 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/en/messages.json @@ -0,0 +1,8 @@ +{ + "extensionName": { + "message": "engine-same-name" + }, + "searchUrl": { + "message": "https://www.google.com/search?q={searchTerms}" + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/gd/messages.json b/toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/gd/messages.json new file mode 100644 index 0000000000..476a9e56cc --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/gd/messages.json @@ -0,0 +1,8 @@ +{ + "extensionName": { + "message": "engine-same-name" + }, + "searchUrl": { + "message": "https://www.example.com/search?q={searchTerms}" + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-same-name/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-same-name/manifest.json new file mode 100644 index 0000000000..ba924b19f4 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-same-name/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "engine-same-name", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-same-name@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "default_locale": "en", + "chrome_settings_overrides": { + "search_provider": { + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine-system-purpose/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-system-purpose/manifest.json new file mode 100644 index 0000000000..01eff3a7cb --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine-system-purpose/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "engine-system-purpose", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine-system-purpose@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "engine-system-purpose", + "search_url": "https://www.google.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "searchbar", + "value": "sb" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "system", + "value": "sys" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine.xml b/toolkit/components/search/tests/xpcshell/data/engine.xml new file mode 100644 index 0000000000..a665e46b0b --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>Test search engine</ShortName> +<Description>A test search engine (based on Google search)</Description> +<InputEncoding>UTF-8</InputEncoding> +<Image width="16" height="16">data:image/png;base64,AAABAAEAEBAAAAEAGABoAwAAFgAAACgAAAAQAAAAIAAAAAEAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADs9Pt8xetPtu9FsfFNtu%2BTzvb2%2B%2Fne4dFJeBw0egA%2FfAJAfAA8ewBBegAAAAD%2B%2FPtft98Mp%2BwWsfAVsvEbs%2FQeqvF8xO7%2F%2F%2F63yqkxdgM7gwE%2FggM%2BfQA%2BegBDeQDe7PIbotgQufcMufEPtfIPsvAbs%2FQvq%2Bfz%2Bf%2F%2B%2B%2FZKhR05hgBBhQI8hgBAgAI9ewD0%2B%2Fg3pswAtO8Cxf4Kw%2FsJvvYAqupKsNv%2B%2Fv7%2F%2FP5VkSU0iQA7jQA9hgBDgQU%2BfQH%2F%2Ff%2FQ6fM4sM4KsN8AteMCruIqqdbZ7PH8%2Fv%2Fg6Nc%2Fhg05kAA8jAM9iQI%2BhQA%2BgQDQu6b97uv%2F%2F%2F7V8Pqw3eiWz97q8%2Ff%2F%2F%2F%2F7%2FPptpkkqjQE4kwA7kAA5iwI8iAA8hQCOSSKdXjiyflbAkG7u2s%2F%2B%2F%2F39%2F%2F7r8utrqEYtjQE8lgA7kwA7kwA9jwA9igA9hACiWSekVRyeSgiYSBHx6N%2F%2B%2Fv7k7OFRmiYtlAA5lwI7lwI4lAA7kgI9jwE9iwI4iQCoVhWcTxCmb0K%2BooT8%2Fv%2F7%2F%2F%2FJ2r8fdwI1mwA3mQA3mgA8lAE8lAE4jwA9iwE%2BhwGfXifWvqz%2B%2Ff%2F58u%2Fev6Dt4tr%2B%2F%2F2ZuIUsggA7mgM6mAM3lgA5lgA6kQE%2FkwBChwHt4dv%2F%2F%2F728ei1bCi7VAC5XQ7kz7n%2F%2F%2F6bsZkgcB03lQA9lgM7kwA2iQktZToPK4r9%2F%2F%2F9%2F%2F%2FSqYK5UwDKZAS9WALIkFn%2B%2F%2F3%2F%2BP8oKccGGcIRJrERILYFEMwAAuEAAdX%2F%2Ff7%2F%2FP%2B%2BfDvGXQLIZgLEWgLOjlf7%2F%2F%2F%2F%2F%2F9QU90EAPQAAf8DAP0AAfMAAOUDAtr%2F%2F%2F%2F7%2B%2Fu2bCTIYwDPZgDBWQDSr4P%2F%2Fv%2F%2F%2FP5GRuABAPkAA%2FwBAfkDAPAAAesAAN%2F%2F%2B%2Fz%2F%2F%2F64g1C5VwDMYwK8Yg7y5tz8%2Fv%2FV1PYKDOcAAP0DAf4AAf0AAfYEAOwAAuAAAAD%2F%2FPvi28ymXyChTATRrIb8%2F%2F3v8fk6P8MAAdUCAvoAAP0CAP0AAfYAAO4AAACAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAA</Image> +<Url type="application/x-suggestions+json" method="GET" template="https://suggestqueries.google.com/complete/search?output=firefox&client=firefox&hl={moz:locale}&q={searchTerms}"/> +<Url type="text/html" method="GET" template="https://www.google.com/search"> + <Param name="q" value="{searchTerms}"/> + <!-- Dynamic parameters --> + <MozParam name="channel" condition="purpose" purpose="contextmenu" value="rcs"/> + <MozParam name="channel" condition="purpose" purpose="keyword" value="fflb"/> +</Url> +<SearchForm>http://www.google.com/</SearchForm> +</SearchPlugin> diff --git a/toolkit/components/search/tests/xpcshell/data/engine/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine/manifest.json new file mode 100644 index 0000000000..ea0db2463d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine/manifest.json @@ -0,0 +1,40 @@ +{ + "name": "Test search engine", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine@search.mozilla.org" + } + }, + "hidden": true, + "description": "A test search engine (based on Google search)", + "icons": { + "16": "favicon.ico" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "Test search engine", + "search_url": "https://www.google.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "contextmenu", + "value": "rcs" + }, + { + "name": "channel", + "condition": "purpose", + "purpose": "keyword", + "value": "fflb" + } + ], + "suggest_url": "https://suggestqueries.google.com/complete/search?output=firefox&client=firefox&hl={moz:locale}&q={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engine2.xml b/toolkit/components/search/tests/xpcshell/data/engine2.xml new file mode 100644 index 0000000000..9957bfdf48 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine2.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> + <ShortName>A second test engine</ShortName> + <Description>A second test search engine (based on DuckDuckGo)</Description> + <InputEncoding>UTF-8</InputEncoding> + <LongName>A second test search engine (based on DuckDuckGo)</LongName> + <Image width="16" height="16">data:image/vnd.microsoft.icon;base64,AAABAAMAEBAAAAEACABoBQAANgAAACAgAAABAAgAqAgAAJ4FAAAwMAAAAQAIAKgOAABGDgAAKAAAABAAAAAgAAAAAQAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUvgAAFMAAABfDAAAYwQAAGscAAB7KAAAgzAAAIcwAACHNAAAizgAAI9AAACTQAAAnzAAAJtMAACnXABktyAAALtgAAC/bAAAx3QAAM+AAADbjAAA34wAAOOQAADzYAAA65wAWPdcAADvoAABG1QC6cg0AAFXdAERS0AAAVd8AAF3cAABp5wAAcOUATGziAAB36AAAeugATHLqAF165ABlg+0A0qRkAACS7wAAlO8AeI7nAIGX6gCVndwAAKX1AACr9gAAr/YAALX4AAC3+QCdrvIAALz6AAC9+QAAv/kAAMD7AADH+wAAyvwAAND9ALHB9QAA0vwA5c68AADT/gAA1P0AANT+AADU/wAA1/8AANj/AADZ/wAA3P8AAN3/AGrV+wAA3/8AAOH/AMrS9ADs3tYA39zmAOHj8AB+6v8A5ur6AKDw/wCq8f8A9fDtAPby7wD38vAA6/D8APfz8QD69O4A8PP8APL0/AD69/IA+/fzAPr39QDd+f8A+/n1APb4/QD7+fgA+/r5AOz8/gD1/P8A+vz/AP7+/QD//v8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABpLGhoaGhQHgEAAQEBAQFpGUtoaGhoSw8CAwICAgICAiNQaGhoaC0EBAwgIBsEBAQnWmhoaGgnIjY5PUBHOyUFLWhoaGheR0MdBgYGByQrCEtoaGhoT0I3CQkJCQkJCQlQaGhoaFI/QTIzNSoXCgsLWmhoaGhoUUVEQ0RKRDEfDWhoXFxoaGhjZWRILzpJRjhoXBwcWGhoaFtbYg4QIS8waFwcKVhoaF8cHF8REREREWhnXFxoaGhfHClOEhISEhJVU2hoaGhoaF9fNBMTExMTVz5MZmhoaFdMTSYUFBQUFDxhVGhoaGhdPi4VFhYWFhZpVmBoaGhoWSgYGhoaGhppgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFL8AABTAAAEWvAABF74AABbCAAAXwwABGMAAABjDAAAYxAAAGsUAABrGAAAbxgAAG8cAAh3BAAEcxQAAHMgAAR3HAAgewgAAHcoAAB/LAAAgzAAFIckAiEYiAAAhzgAAI88AACPQAAAk0AAAJc8AACXSAAUmzQAAJ9IAACfTABAqxwAAKNQAjE0rAAAp1QAAKdYAACrXAAAr2AAGLdMAAC3ZAAAu2wAAL9wAADPVAAAx3QAAMt8AADPgAAA04QAAONQAADnXAAA24wAGN94ACjfdAAA34wAAN+QACznbAAA45QAJOeAAFTzUABY81AAAOeYAFj3UAAo74AAAP9YAADrnAAs74AAAPtoABT/pAABF2wAARd0AAEbaAABI1gAASdoAEETmAABJ2wAAS98AAFHfAAFV3ABTc2EAWYA+AAFa4gAAXd0AAF3hADyeAAABZd4AAWTjADZf5wABZuIAQKETAD2jDwBGZ9gARKEYADhk6wA8pBMAPKUVAK+DbABKbN8ATGziAAF15ABTpioAaKE1AEGtJAABfOkAOrIiAH6hRQABgOkAAYLmAAGC5wACgucAXXrkAAGE5wBEr0AAAYToAAGG6gA9s0AAObkuAD20QQA5ui8AaIHhAEe3OABxgeAAAo3sAFasbABshukAOcA5AGmI7gABleoAbovsAAKZ7QCFtmEAAp7vAD7CagCBl+oAPsNuAAKo8QACqvIAA6zyAAOs9AACr/QAkKfxAGLJlQBfypUAZMqWAAO49QDSuawAc8qaAMPCngADwfgAA8H5AAPE+QAEyPsABMr7AATO/AAE0PwABND9AATR/QAE0v0ACNP9ABvW/QDj1MwAMtr9ADvc/QDK0vQA59rTAOjb1ADp3tcA6+DaAG/l/gB15v4A29/0AO3k3wDu5eAA4eT3AJDr/gDy6+cA5uj3APLs6ADj6PsA8+zpAObq+gDz7uoA8+7rANHt+gD07usA6e35APbx7wDy9PwA5fr/APv7+wD2+/8A7fz/APz8+gDw/P8A/fz7APv8/gD9/f4A+P7/AP/+/gD+/v8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyMgAAAAAPYyDb4HHx8fHw05dUwAAAAAAAAAAAAAAyMjIAQEBAQFhkYNyXmV3Y2h6dF1TAgEBAQEBAQEBAQEByAQEBAQEBG2Og3JedXxnWYV0XVMDBAQEBAQEBAQEBAQEBQUFBQURhIyDcl5zfGdbhXRdUwYFBQUFBQUFBQUFBQUICAgICDuijYNyXmV3ZE96dF1TBwgICAgICAgICAgICAoKCgoKYbOMg5K/x8esCg5OWFMJCgoKCgoKCgoKCgoKDAwMDAxtusfHx8fHx3gMDAwLDQwMDAwMDAwMDAwMDAwPDw8PEITHx8fHx8fHFQ8PDw8PDw8PDw8PDw8PDw8PDxISEhIgosfHx8fHx8YSEhISEhISEhISEhISEhISEhISExMTEzuzx8fHx8fHrxMTExMTExMTExMTExMTExMTExMUFBQUYbrHx8fHx8epFBQUboiXnJqGVEcUFBQUFBQUFBcXFxdtx8fHx8fHx7YbbJyYj2tigpObnIAXFxcXFxcXGBgYHYTHx8fHx8e7npybcE0wGBgYP1F+ahgYGBgYGBgaGho6osfHx8fHx6icnIcZGhoaGhoaGhoaGhoaGhoaGhwcHGGzx8fHx8fHp5ycnEgeRjErHBwcHBwcHBwcHBwcISEhbbrHx8fHx8e+oZycnJycnJyViVdEISEhISEhISEhIR+Ex8fHx8fHx8fEraCdnJycnJycnJZxSiEhISEhISMjJ6LHx8fHx8fHx8fHx8fAvcBCVXmUnJyZUiMjIyMjJSU6s8fHx6Wlx8fHx8fHx8fHxyUkJUxpmZuKJSUlJSUmJmG6x8WjIhaqx8fHx8fHtLTHJiYmJkVQZksmJiYmJigobcXHx6QiX6rHx8fHx7AiFrUoKCgoKCgoKCgoKCgoKSmEx8fHx6urx8fHx8fHsiJfeykpKSkpKSkpKSkpKSkqKoTHt8fHx8fHx8fHx8fHubk0KioqKioqKioqKioqKiwsbcemx8fHx8fHx8fHwcfHuCwsLCwsLCwsLCwsLCwsLi43s8eQkJ/Hx8fHx8eukJA5Li4uLi4uLi4uLi4uLi4uLi2Ex8fHx8fHx8fHx8fHfy4uLi4uLi4uLi4uLi4uLi8vLzOzx8fHx8fHx8fHx4svLy8vLy8vLy8vLy8vLy8vMjIyMkGEvMfHx8fHx7FWMjIyMjIyMjIyMjIyMjIyMjI2NjY2MjI+drPHx8d9NTY2NjY2NjY2NjY2NjY2NjY2Njg4NlrHx8fHx8fGSTg4ODg4ODg4ODg4ODg4ODg4ODg4yEBAPGDHx8fCXEBAQEBAQEBAQEBAQEBAQEBAQEBAQMjIyENDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0PIyMAAAAOAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAHAAAADKAAAADAAAABgAAAAAQAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARwQAAE8IAABXEAAAXxQAAGcYAARvHAAAfwAAAHsoAFSucAIdGHgAAIs0Ai0kjAAAlzwAHJc8AID59AAAo0gCPTSgAACnTACQ8lwAYMsgAADDaAAI01AA+WkgAADTeABU4zgAEM+YAl1o3AAg52QAANukAADfqAAA65AA5Y0cAAEDYABc+3QAARtYANEPPAJ1lQgA6eSUAGUbeAAFK2wAVRucAMknWAAJQ2gA8hBkANoQfAB9M5QAXTeYANo0FAABT3gAmUeEAVoMvAEaYAAAAXeIASJIcAABg3gA9WeEAPZ4AADefBACneGMAA2TiAAFn3gAwX+oAU4ZkADqiDwAAauIArH9hAKx9aABWZtYAW4pfAFJp2gAAcOIAX2raAEOkJABJoi4AXKErAEtt4ABGpykANa0bAElu6gA6qiYAaXLbAAZ66ABFricAPq4tAFZ24wBseNoAeaVCALiPdwBtedsANrgeAF955gBBsjIAY6dMAFx86gBYfu0AAIjrAD61RwBCuzMAZILpAAKM6QCipWMAYrZCAL+bhwA3wjQAaIbuADy6UABrifEAAJXtAHCL6wBZtmgATLduAG2O8AACnO8APMFkAIuW4wCltXMAq7J+AIKX6AB9mOsAyamaAImb5AA5xXMAi6DjAACq9ACsuYgAT8iDAAaw8wBBzIIAjqbyAACz9wCVqu0AmK3xAJ+u8AClru4AC8P2AKa48wAAx/wAAMv5ALvQtgAJz/4AAND/ALPB9QDDzscAANP8AMDF7QAQ1PwAwsz3ACna/AC9zfoANtv8AObYzACr4sYA6NrOAMvS8wCu5M8ATuD/AGDi/wCy59MAaOP/ANPn1ABp5vwA5eHjAOzk2wDU3vgAwurYAIHq/ADr6t0Akev/AObm8wDh5vYA9e3kAOTq+gDz8OsAsPH/AOjt/QC38v8A6u//AN327QDx8f4A+PT2AM73/gDz9foA+/jzAPX2/AD8+fQA3fr8AP369QD4+v8A+v37AP/++AD8//4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC/vwAAAAAAAAAAAAAAAAF2obO9vr6+vr6+vr28RwAAAAAAAAAAAAAAAAAAAAAAv7+/AAYAAAAAAAAAAAABAgGZfW18vr6+vr6+vr6pAAAAAAAAAAAAAAAAAAAAAQAGAL8BAQEBQ5Cyvru+vb1QAxiff3FgSWS+vr6+vr5HAggWDgETvb67vr2+vrWQQwEGAQEBAQFyvlUTAAEBAQEAAUukfXFgTzhMYFJKc44+ST9IKwEBAQEBAQEBAQFVvnIBAQEBAUO+IwEBAQEAAQEBAVSdf3FgT0hnZ1lNSHlpWz85LwEBAQEBAQEBAQEBI7tDAQECApBYAgICAgICAgICAnWaf3FgUjhnZ2FPP31pUzhILwICAgICAgICAgICAliQAgICArUCAgICAgICAgICApmaf3FgTzlnZ1lNSHlpUzg5LwICAgICAgICAgICAgK1AgICAr0CAgICAgICAgICS6mXf3FgTzNWZVtPMm5pWz9ILAICAgICAgICAgICAgK7AgICAr4CAgICAgICAgICVLWXf3FcdL6+vr6FBAJEUzg4JQICAgICAgICAgICAgK9AgICArsCAgICAgICAgICdb6xiqa+vr6+vrMpCgICEjU4HwICAgICAgICAgICAgK7AgIDA74DAwMDAwMDAwMDmb6+vr6+vr6+vrAKAwMDAwMSAwMDAwMDAwMDAwMDAwO+AwMDA7sDAwMDAwMDAwMDqb6+vr6+vr6+voUDAwMDAwMDAwMDAwMDAwMDAwMDAwO9AwMFBb4FBQUFBQUFBQVLtb6+vr6+vr6+vkUFBQUFBQUFBQUFBQUFBQUFBQUFBQW+BQUFBbsFBQUFBQUFBQVavr6+vr6+vr6+vikFBQUFBQUFBQUFBQUFBQUFBQUFBQW9BQUHB7sHBwcHBwcHBwd1vr6+vr6+vr6+vgcHBwcHBwcHBwcHBwcHBwcHBwcHBwe+BwcHB70HBwcHBwcHBweZvr6+vr6+vr6+vgcHBwciUXuIj4+PhmsqBwcHBwcHBwe+BwcKCr4MCgoKCgoKCgqrvr6+vr6+vr6+vgoKY4uRj5GRkYyPi4uRiDYKCgoKCgq7CgoKCr4MCgoKCgoKCku1vr6+vr6+vr6+r4aPi4twOycgIjBAcIaLj488CgoKCgq9CgoKCr4KCgoKCgoKClS+vr6+vr6+vr60kYyMiyoKCgoKCgoKCgoVKkYVCgoKCgq9CgoMDLsMDAwMDAwMDHW+vr6+vr6+vrubjI+RYw0KCgoKCgoMCgwMDAwMDAwMCgy+CgwMDL4MDAwMDAwMDJm+vr6+vr6+vr2Tj4+PiBUMDAwMDAwMDAwMDAwMDAwMDAy9DwwMDL4MDAwMDAwMIam+vr6+vr6+vrugi4+Mj4uIi4yIflEgDAwMDAwMDAwMDAy8DAwMDL4MDAwMDAwMS7W+vr6+vr6+vr6+npGLj4yPj4+Pj4+PiF8gDAwMDAwMDAy+DAwPDL0PDwwPDA8PWr6+vr6+vr6+vr6+vq2Tj4+Lj4yPj5GLj4+PgTsPDA8MDwy9DA8PD74PDw8PDw8Pdb6+vr6+vr6+vr6+vr67ua2npZyVjI+LkY+Rj4+INA8PDw++Dw8PD70PDw8PDw8Pmb6+vr6+vr6+vr6+vr69vr6+vr69VCpffoyLjI+Jj4EPDw++Dw8PD70PDw8PDw8Pq76+vqEaGqq+vr6+vr6+vr68vr6+NxEPDw82e5GMiVEPDw++Dw8REbwRERERERFLtb6+vgsQCzq+vr6+vr6+vpYaGqy7ERERERERERERERERERG9EREUEb4RFBEUERRdvr6+vhAQoma+vr6+vr6+vgkaCTq+FBEUERQRFBEUERQRFBG+ERQUFL4UFBQUFBR1vr6+vqpBQra+vr6+vr6+uxAQoWa+FBQUFBQUFBQUFBQUFBS9FBQUFL4UFBQUFBR2vr6+vr6+vr6+vr6+vr6+vqpCQrayFBQUFBQUFBQUFBQUFBS+FBQUFL4UFBQUFBRivr6qvr6+vr6+vr6+vr6+vr6+vr6CFBQUFBQUFBQUFBQUFBS9FBQXFL4UFxQXFBdLvrNXvr6+vr6+vr6+vr6+vr6+vr4UFxQXFBcUFxQXFBcUFxS+FBcUF74XFBcUFxQXvr62JFd3s72+vr6+vr67rLO7mKgXFBcUFxQXFBcUFxQXFBe+FxQXF74XFxcXFxcXrr6+vrq9vr6+vr6+vr6+uCRXljEXFxcXFxcXFxcXFxcXFxe+FxcXF74XFxcXFxcXhL2+vr6+vr6+vr6+vr6+vb69gxcXFxcXFxcXFxcXFxcXFxe+FxcXF74XFxcXFxcXG667vr6+vr6+vr6+vr6+vrupFxcXFxcXFxcXFxcXFxcXFxe+FxcXHr4eFx4XHhceFx68vb6+vr6+vr6+vr69vrUXHhceFx4XHhceFx4XHhceFx6+HhceF74XHhceFx4XHhceeLu9vb6+vr6+vb6zhxceFx4XHhceFx4XHhceFx4XHhe+Fx4eHr4eHh4eHh4eHh4eHhctXZm+vr6+sGIeHh4eHh4eHh4eHh4eHh4eHh4eHh69Hh4eHr0eHh4eHh4eHmJsdoKNo7u+vr61LR4eHh4eHh4eHh4eHh4eHh4eHh4eHh69Hh4eHLUcHhweHB4cG6u8vr6+vr6+voMeHB4cHhweHB4cHhweHB4cHhweHB4cHhy1HB4cHpJqHB4cHhweHCaZvr6+vr2DLhwcHhweHB4cHhweHB4cHhweHB4cHhweHGqSHhwcHF6+PRwcHBwcHlR6hINoTigZGRwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcPb5eHBwcHByAvW8cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBxvvYAcHBwcHBwcXpS3vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vreUXhwcHBy/HBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHL+/vx0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dv7/AAAAAAAMAAIAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAQAAwAAAAAADAAA=</Image> + <Url type="text/html" method="get" template="https://duckduckgo.com/?q={searchTerms}"/> +</OpenSearchDescription> diff --git a/toolkit/components/search/tests/xpcshell/data/engine2/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine2/manifest.json new file mode 100644 index 0000000000..0ec29fe5f5 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engine2/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "A second test engine", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine2@search.mozilla.org" + } + }, + "hidden": true, + "description": "A second test search engine (based on DuckDuckGo)", + "icons": { + "16": "data:image/vnd.microsoft.icon;base64,AAABAAMAEBAAAAEACABoBQAANgAAACAgAAABAAgAqAgAAJ4FAAAwMAAAAQAIAKgOAABGDgAAKAAAABAAAAAgAAAAAQAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUvgAAFMAAABfDAAAYwQAAGscAAB7KAAAgzAAAIcwAACHNAAAizgAAI9AAACTQAAAnzAAAJtMAACnXABktyAAALtgAAC/bAAAx3QAAM+AAADbjAAA34wAAOOQAADzYAAA65wAWPdcAADvoAABG1QC6cg0AAFXdAERS0AAAVd8AAF3cAABp5wAAcOUATGziAAB36AAAeugATHLqAF165ABlg+0A0qRkAACS7wAAlO8AeI7nAIGX6gCVndwAAKX1AACr9gAAr/YAALX4AAC3+QCdrvIAALz6AAC9+QAAv/kAAMD7AADH+wAAyvwAAND9ALHB9QAA0vwA5c68AADT/gAA1P0AANT+AADU/wAA1/8AANj/AADZ/wAA3P8AAN3/AGrV+wAA3/8AAOH/AMrS9ADs3tYA39zmAOHj8AB+6v8A5ur6AKDw/wCq8f8A9fDtAPby7wD38vAA6/D8APfz8QD69O4A8PP8APL0/AD69/IA+/fzAPr39QDd+f8A+/n1APb4/QD7+fgA+/r5AOz8/gD1/P8A+vz/AP7+/QD//v8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABpLGhoaGhQHgEAAQEBAQFpGUtoaGhoSw8CAwICAgICAiNQaGhoaC0EBAwgIBsEBAQnWmhoaGgnIjY5PUBHOyUFLWhoaGheR0MdBgYGByQrCEtoaGhoT0I3CQkJCQkJCQlQaGhoaFI/QTIzNSoXCgsLWmhoaGhoUUVEQ0RKRDEfDWhoXFxoaGhjZWRILzpJRjhoXBwcWGhoaFtbYg4QIS8waFwcKVhoaF8cHF8REREREWhnXFxoaGhfHClOEhISEhJVU2hoaGhoaF9fNBMTExMTVz5MZmhoaFdMTSYUFBQUFDxhVGhoaGhdPi4VFhYWFhZpVmBoaGhoWSgYGhoaGhppgAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFL8AABTAAAEWvAABF74AABbCAAAXwwABGMAAABjDAAAYxAAAGsUAABrGAAAbxgAAG8cAAh3BAAEcxQAAHMgAAR3HAAgewgAAHcoAAB/LAAAgzAAFIckAiEYiAAAhzgAAI88AACPQAAAk0AAAJc8AACXSAAUmzQAAJ9IAACfTABAqxwAAKNQAjE0rAAAp1QAAKdYAACrXAAAr2AAGLdMAAC3ZAAAu2wAAL9wAADPVAAAx3QAAMt8AADPgAAA04QAAONQAADnXAAA24wAGN94ACjfdAAA34wAAN+QACznbAAA45QAJOeAAFTzUABY81AAAOeYAFj3UAAo74AAAP9YAADrnAAs74AAAPtoABT/pAABF2wAARd0AAEbaAABI1gAASdoAEETmAABJ2wAAS98AAFHfAAFV3ABTc2EAWYA+AAFa4gAAXd0AAF3hADyeAAABZd4AAWTjADZf5wABZuIAQKETAD2jDwBGZ9gARKEYADhk6wA8pBMAPKUVAK+DbABKbN8ATGziAAF15ABTpioAaKE1AEGtJAABfOkAOrIiAH6hRQABgOkAAYLmAAGC5wACgucAXXrkAAGE5wBEr0AAAYToAAGG6gA9s0AAObkuAD20QQA5ui8AaIHhAEe3OABxgeAAAo3sAFasbABshukAOcA5AGmI7gABleoAbovsAAKZ7QCFtmEAAp7vAD7CagCBl+oAPsNuAAKo8QACqvIAA6zyAAOs9AACr/QAkKfxAGLJlQBfypUAZMqWAAO49QDSuawAc8qaAMPCngADwfgAA8H5AAPE+QAEyPsABMr7AATO/AAE0PwABND9AATR/QAE0v0ACNP9ABvW/QDj1MwAMtr9ADvc/QDK0vQA59rTAOjb1ADp3tcA6+DaAG/l/gB15v4A29/0AO3k3wDu5eAA4eT3AJDr/gDy6+cA5uj3APLs6ADj6PsA8+zpAObq+gDz7uoA8+7rANHt+gD07usA6e35APbx7wDy9PwA5fr/APv7+wD2+/8A7fz/APz8+gDw/P8A/fz7APv8/gD9/f4A+P7/AP/+/gD+/v8A////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyMgAAAAAPYyDb4HHx8fHw05dUwAAAAAAAAAAAAAAyMjIAQEBAQFhkYNyXmV3Y2h6dF1TAgEBAQEBAQEBAQEByAQEBAQEBG2Og3JedXxnWYV0XVMDBAQEBAQEBAQEBAQEBQUFBQURhIyDcl5zfGdbhXRdUwYFBQUFBQUFBQUFBQUICAgICDuijYNyXmV3ZE96dF1TBwgICAgICAgICAgICAoKCgoKYbOMg5K/x8esCg5OWFMJCgoKCgoKCgoKCgoKDAwMDAxtusfHx8fHx3gMDAwLDQwMDAwMDAwMDAwMDAwPDw8PEITHx8fHx8fHFQ8PDw8PDw8PDw8PDw8PDw8PDxISEhIgosfHx8fHx8YSEhISEhISEhISEhISEhISEhISExMTEzuzx8fHx8fHrxMTExMTExMTExMTExMTExMTExMUFBQUYbrHx8fHx8epFBQUboiXnJqGVEcUFBQUFBQUFBcXFxdtx8fHx8fHx7YbbJyYj2tigpObnIAXFxcXFxcXGBgYHYTHx8fHx8e7npybcE0wGBgYP1F+ahgYGBgYGBgaGho6osfHx8fHx6icnIcZGhoaGhoaGhoaGhoaGhoaGhwcHGGzx8fHx8fHp5ycnEgeRjErHBwcHBwcHBwcHBwcISEhbbrHx8fHx8e+oZycnJycnJyViVdEISEhISEhISEhIR+Ex8fHx8fHx8fEraCdnJycnJycnJZxSiEhISEhISMjJ6LHx8fHx8fHx8fHx8fAvcBCVXmUnJyZUiMjIyMjJSU6s8fHx6Wlx8fHx8fHx8fHxyUkJUxpmZuKJSUlJSUmJmG6x8WjIhaqx8fHx8fHtLTHJiYmJkVQZksmJiYmJigobcXHx6QiX6rHx8fHx7AiFrUoKCgoKCgoKCgoKCgoKSmEx8fHx6urx8fHx8fHsiJfeykpKSkpKSkpKSkpKSkqKoTHt8fHx8fHx8fHx8fHubk0KioqKioqKioqKioqKiwsbcemx8fHx8fHx8fHwcfHuCwsLCwsLCwsLCwsLCwsLi43s8eQkJ/Hx8fHx8eukJA5Li4uLi4uLi4uLi4uLi4uLi2Ex8fHx8fHx8fHx8fHfy4uLi4uLi4uLi4uLi4uLi8vLzOzx8fHx8fHx8fHx4svLy8vLy8vLy8vLy8vLy8vMjIyMkGEvMfHx8fHx7FWMjIyMjIyMjIyMjIyMjIyMjI2NjY2MjI+drPHx8d9NTY2NjY2NjY2NjY2NjY2NjY2Njg4NlrHx8fHx8fGSTg4ODg4ODg4ODg4ODg4ODg4ODg4yEBAPGDHx8fCXEBAQEBAQEBAQEBAQEBAQEBAQEBAQMjIyENDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0PIyMAAAAOAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAHAAAADKAAAADAAAABgAAAAAQAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARwQAAE8IAABXEAAAXxQAAGcYAARvHAAAfwAAAHsoAFSucAIdGHgAAIs0Ai0kjAAAlzwAHJc8AID59AAAo0gCPTSgAACnTACQ8lwAYMsgAADDaAAI01AA+WkgAADTeABU4zgAEM+YAl1o3AAg52QAANukAADfqAAA65AA5Y0cAAEDYABc+3QAARtYANEPPAJ1lQgA6eSUAGUbeAAFK2wAVRucAMknWAAJQ2gA8hBkANoQfAB9M5QAXTeYANo0FAABT3gAmUeEAVoMvAEaYAAAAXeIASJIcAABg3gA9WeEAPZ4AADefBACneGMAA2TiAAFn3gAwX+oAU4ZkADqiDwAAauIArH9hAKx9aABWZtYAW4pfAFJp2gAAcOIAX2raAEOkJABJoi4AXKErAEtt4ABGpykANa0bAElu6gA6qiYAaXLbAAZ66ABFricAPq4tAFZ24wBseNoAeaVCALiPdwBtedsANrgeAF955gBBsjIAY6dMAFx86gBYfu0AAIjrAD61RwBCuzMAZILpAAKM6QCipWMAYrZCAL+bhwA3wjQAaIbuADy6UABrifEAAJXtAHCL6wBZtmgATLduAG2O8AACnO8APMFkAIuW4wCltXMAq7J+AIKX6AB9mOsAyamaAImb5AA5xXMAi6DjAACq9ACsuYgAT8iDAAaw8wBBzIIAjqbyAACz9wCVqu0AmK3xAJ+u8AClru4AC8P2AKa48wAAx/wAAMv5ALvQtgAJz/4AAND/ALPB9QDDzscAANP8AMDF7QAQ1PwAwsz3ACna/AC9zfoANtv8AObYzACr4sYA6NrOAMvS8wCu5M8ATuD/AGDi/wCy59MAaOP/ANPn1ABp5vwA5eHjAOzk2wDU3vgAwurYAIHq/ADr6t0Akev/AObm8wDh5vYA9e3kAOTq+gDz8OsAsPH/AOjt/QC38v8A6u//AN327QDx8f4A+PT2AM73/gDz9foA+/jzAPX2/AD8+fQA3fr8AP369QD4+v8A+v37AP/++AD8//4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC/vwAAAAAAAAAAAAAAAAF2obO9vr6+vr6+vr28RwAAAAAAAAAAAAAAAAAAAAAAv7+/AAYAAAAAAAAAAAABAgGZfW18vr6+vr6+vr6pAAAAAAAAAAAAAAAAAAAAAQAGAL8BAQEBQ5Cyvru+vb1QAxiff3FgSWS+vr6+vr5HAggWDgETvb67vr2+vrWQQwEGAQEBAQFyvlUTAAEBAQEAAUukfXFgTzhMYFJKc44+ST9IKwEBAQEBAQEBAQFVvnIBAQEBAUO+IwEBAQEAAQEBAVSdf3FgT0hnZ1lNSHlpWz85LwEBAQEBAQEBAQEBI7tDAQECApBYAgICAgICAgICAnWaf3FgUjhnZ2FPP31pUzhILwICAgICAgICAgICAliQAgICArUCAgICAgICAgICApmaf3FgTzlnZ1lNSHlpUzg5LwICAgICAgICAgICAgK1AgICAr0CAgICAgICAgICS6mXf3FgTzNWZVtPMm5pWz9ILAICAgICAgICAgICAgK7AgICAr4CAgICAgICAgICVLWXf3FcdL6+vr6FBAJEUzg4JQICAgICAgICAgICAgK9AgICArsCAgICAgICAgICdb6xiqa+vr6+vrMpCgICEjU4HwICAgICAgICAgICAgK7AgIDA74DAwMDAwMDAwMDmb6+vr6+vr6+vrAKAwMDAwMSAwMDAwMDAwMDAwMDAwO+AwMDA7sDAwMDAwMDAwMDqb6+vr6+vr6+voUDAwMDAwMDAwMDAwMDAwMDAwMDAwO9AwMFBb4FBQUFBQUFBQVLtb6+vr6+vr6+vkUFBQUFBQUFBQUFBQUFBQUFBQUFBQW+BQUFBbsFBQUFBQUFBQVavr6+vr6+vr6+vikFBQUFBQUFBQUFBQUFBQUFBQUFBQW9BQUHB7sHBwcHBwcHBwd1vr6+vr6+vr6+vgcHBwcHBwcHBwcHBwcHBwcHBwcHBwe+BwcHB70HBwcHBwcHBweZvr6+vr6+vr6+vgcHBwciUXuIj4+PhmsqBwcHBwcHBwe+BwcKCr4MCgoKCgoKCgqrvr6+vr6+vr6+vgoKY4uRj5GRkYyPi4uRiDYKCgoKCgq7CgoKCr4MCgoKCgoKCku1vr6+vr6+vr6+r4aPi4twOycgIjBAcIaLj488CgoKCgq9CgoKCr4KCgoKCgoKClS+vr6+vr6+vr60kYyMiyoKCgoKCgoKCgoVKkYVCgoKCgq9CgoMDLsMDAwMDAwMDHW+vr6+vr6+vrubjI+RYw0KCgoKCgoMCgwMDAwMDAwMCgy+CgwMDL4MDAwMDAwMDJm+vr6+vr6+vr2Tj4+PiBUMDAwMDAwMDAwMDAwMDAwMDAy9DwwMDL4MDAwMDAwMIam+vr6+vr6+vrugi4+Mj4uIi4yIflEgDAwMDAwMDAwMDAy8DAwMDL4MDAwMDAwMS7W+vr6+vr6+vr6+npGLj4yPj4+Pj4+PiF8gDAwMDAwMDAy+DAwPDL0PDwwPDA8PWr6+vr6+vr6+vr6+vq2Tj4+Lj4yPj5GLj4+PgTsPDA8MDwy9DA8PD74PDw8PDw8Pdb6+vr6+vr6+vr6+vr67ua2npZyVjI+LkY+Rj4+INA8PDw++Dw8PD70PDw8PDw8Pmb6+vr6+vr6+vr6+vr69vr6+vr69VCpffoyLjI+Jj4EPDw++Dw8PD70PDw8PDw8Pq76+vqEaGqq+vr6+vr6+vr68vr6+NxEPDw82e5GMiVEPDw++Dw8REbwRERERERFLtb6+vgsQCzq+vr6+vr6+vpYaGqy7ERERERERERERERERERG9EREUEb4RFBEUERRdvr6+vhAQoma+vr6+vr6+vgkaCTq+FBEUERQRFBEUERQRFBG+ERQUFL4UFBQUFBR1vr6+vqpBQra+vr6+vr6+uxAQoWa+FBQUFBQUFBQUFBQUFBS9FBQUFL4UFBQUFBR2vr6+vr6+vr6+vr6+vr6+vqpCQrayFBQUFBQUFBQUFBQUFBS+FBQUFL4UFBQUFBRivr6qvr6+vr6+vr6+vr6+vr6+vr6CFBQUFBQUFBQUFBQUFBS9FBQXFL4UFxQXFBdLvrNXvr6+vr6+vr6+vr6+vr6+vr4UFxQXFBcUFxQXFBcUFxS+FBcUF74XFBcUFxQXvr62JFd3s72+vr6+vr67rLO7mKgXFBcUFxQXFBcUFxQXFBe+FxQXF74XFxcXFxcXrr6+vrq9vr6+vr6+vr6+uCRXljEXFxcXFxcXFxcXFxcXFxe+FxcXF74XFxcXFxcXhL2+vr6+vr6+vr6+vr6+vb69gxcXFxcXFxcXFxcXFxcXFxe+FxcXF74XFxcXFxcXG667vr6+vr6+vr6+vr6+vrupFxcXFxcXFxcXFxcXFxcXFxe+FxcXHr4eFx4XHhceFx68vb6+vr6+vr6+vr69vrUXHhceFx4XHhceFx4XHhceFx6+HhceF74XHhceFx4XHhceeLu9vb6+vr6+vb6zhxceFx4XHhceFx4XHhceFx4XHhe+Fx4eHr4eHh4eHh4eHh4eHhctXZm+vr6+sGIeHh4eHh4eHh4eHh4eHh4eHh4eHh69Hh4eHr0eHh4eHh4eHmJsdoKNo7u+vr61LR4eHh4eHh4eHh4eHh4eHh4eHh4eHh69Hh4eHLUcHhweHB4cG6u8vr6+vr6+voMeHB4cHhweHB4cHhweHB4cHhweHB4cHhy1HB4cHpJqHB4cHhweHCaZvr6+vr2DLhwcHhweHB4cHhweHB4cHhweHB4cHhweHGqSHhwcHF6+PRwcHBwcHlR6hINoTigZGRwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcPb5eHBwcHByAvW8cHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBxvvYAcHBwcHBwcXpS3vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vr6+vreUXhwcHBy/HBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHL+/vx0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dv7/AAAAAAAMAAIAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAQAAwAAAAAADAAA=" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "A second test engine", + "search_url": "https://duckduckgo.com/?q={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/engineImages.xml b/toolkit/components/search/tests/xpcshell/data/engineImages.xml new file mode 100644 index 0000000000..65b550b31b --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engineImages.xml @@ -0,0 +1,22 @@ +<!-- 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/. --> + +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> + <ShortName>IconsTest</ShortName> + <Description>IconsTest. Search by Test.</Description> + <InputEncoding>UTF-8</InputEncoding> + <Image width="16" height="16">data:image/x-icon;base64,ico16</Image> + <Image width="32" height="32">data:image/x-icon;base64,ico32</Image> + <Image width="74" height="74">data:image/png;base64,ico74</Image> + <Url type="application/x-suggestions+json" template="http://api.bing.com/osjson.aspx"> + <Param name="query" value="{searchTerms}"/> + <Param name="form" value="MOZW"/> + </Url> + <Url type="text/html" method="GET" template="http://www.bing.com/search"> + <Param name="q" value="{searchTerms}"/> + <MozParam name="pc" condition="pref" pref="ms-pc"/> + <Param name="form" value="MOZW"/> + </Url> + <SearchForm>http://www.bing.com/search</SearchForm> +</SearchPlugin> diff --git a/toolkit/components/search/tests/xpcshell/data/engineImages/manifest.json b/toolkit/components/search/tests/xpcshell/data/engineImages/manifest.json new file mode 100644 index 0000000000..80c4355ce8 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engineImages/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "IconsTest", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engineImages@search.mozilla.org" + } + }, + "hidden": true, + "description": "IconsTest. Search by Test.", + "icons": { + "16": "data:image/x-icon;base64,ico16" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "IconsTest", + "search_url": "https://www.bing.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "form", + "value": "MOZW" + }, + { + "name": "pc", + "condition": "pref", + "pref": "ms-pc" + } + ], + "suggest_url": "https://api.bing.com/osjson.aspxquery={searchTerms}&form=MOZW" + } + } +} 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 0000000000..dbf1562317 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engineMaker.sjs @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * 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.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"); + createOpenSearchEngine(response, engineData); +} + +/** + * 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 type = "type='application/x-suggestions+json'"; + if (engineData.alternativeJSONType) { + type = "type='application/json' rel='suggestions'"; + } + let image = ""; + if (engineData.image) { + image = `<Image width="16" height="16">${engineData.baseURL}${engineData.image}</Image>`; + } + + 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> + ${image} + <Url ${type} 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); +} diff --git a/toolkit/components/search/tests/xpcshell/data/engines-no-order-hint.json b/toolkit/components/search/tests/xpcshell/data/engines-no-order-hint.json new file mode 100644 index 0000000000..67bf263d41 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engines-no-order-hint.json @@ -0,0 +1,70 @@ +{ + "data": [ + { + "webExtension": { + "id": "engine@search.mozilla.org" + }, + "appliesTo": [{ + "included": { "everywhere": true }, + "default": "yes" + }] + }, + { + "webExtension": { + "id": "engine-rel-searchform-purpose@search.mozilla.org" + }, + "orderHint": 1000, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de", "fr"] } }, + "default": "no" + }] + }, + { + "webExtension": { + "id": "engine-chromeicon@search.mozilla.org" + }, + "orderHint": 1000, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de", "fr"] } }, + "default": "no" + }, { + "included": { "regions": ["ru"] }, + "default": "no" + }] + }, + { + "webExtension": { + "id": "engine-resourceicon@search.mozilla.org" + }, + "appliesTo": [{ + "included": { "locales": { "matches": ["en-US", "fr"] } }, + "excluded": { + "regions": ["ru"] + }, + "default": "no" + }] + }, + { + "webExtension": { + "id": "engine-reordered@search.mozilla.org" + }, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de", "fr"] } }, + "default": "no" + }] + }, + { + "webExtension": { + "id": "engine-pref@search.mozilla.org" + }, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de"] } }, + "default": "no" + }] + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/data/engines.json b/toolkit/components/search/tests/xpcshell/data/engines.json new file mode 100644 index 0000000000..8ce0649884 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/engines.json @@ -0,0 +1,83 @@ +{ + "data": [ + { + "webExtension": { + "id": "engine@search.mozilla.org" + }, + "orderHint": 10000, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["gd"] } }, + "default": "yes" + }] + }, + { + "webExtension": { + "id": "engine-pref@search.mozilla.org" + }, + "orderHint": 7000, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de"] } }, + "default": "no", + "defaultPrivate": "yes" + }] + }, + { + "webExtension": { + "id": "engine-rel-searchform-purpose@search.mozilla.org" + }, + "orderHint": 6000, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de", "fr"] } }, + "default": "no" + }, { + "included": { "locales": { "matches": ["gd"] } }, + "orderHint": 9000 + }] + }, + { + "webExtension": { + "id": "engine-chromeicon@search.mozilla.org" + }, + "orderHint": 8000, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de", "fr"] } }, + "default": "no" + }, { + "included": { "regions": ["ru"] }, + "default": "no" + }] + }, + { + "webExtension": { + "id": "engine-resourceicon@search.mozilla.org" + }, + "orderHint": 9000, + "appliesTo": [{ + "included": { "locales": { "matches": ["en-US", "fr"] } }, + "excluded": { "regions": ["ru"] }, + "default": "no" + }, { + "included": { "locales": { "matches": ["gd"] } }, + "default": "yes", + "webExtension": { + "locales": ["gd"] + } + }] + }, + { + "webExtension": { + "id": "engine-reordered@search.mozilla.org" + }, + "orderHint": 5000, + "appliesTo": [{ + "included": { "everywhere": true }, + "excluded": { "locales": { "matches": ["de", "fr"] } }, + "default": "no" + }] + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/af/messages.json b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/af/messages.json new file mode 100644 index 0000000000..29ddd24df5 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/af/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Multilocale" + }, + "extensionDescription": { + "message": "Wikipedia, die vrye ensiklopedie" + }, + "url_lang": { + "message": "af" + }, + "searchUrl": { + "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek" + }, + "suggestUrl": { + "message": "https://af.wikipedia.org/w/api.php" + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/an/messages.json b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/an/messages.json new file mode 100644 index 0000000000..d21d910463 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/an/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Multilocale" + }, + "extensionDescription": { + "message": "A enciclopedia Libre" + }, + "url_lang": { + "message": "an" + }, + "searchUrl": { + "message": "https://an.wikipedia.org/wiki/Especial:Mirar" + }, + "suggestUrl": { + "message": "https://an.wikipedia.org/w/api.php" + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/favicon.ico b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/favicon.ico Binary files differnew file mode 100644 index 0000000000..4314071e24 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/favicon.ico diff --git a/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/manifest.json b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/manifest.json new file mode 100644 index 0000000000..1ac790b088 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "__MSG_extensionName__", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "multilocale@search.mozilla.org" + } + }, + "hidden": true, + "description": "__MSG_extensionDescription__", + "icons": { + "16": "favicon.ico" + }, + "default_locale": "af", + "chrome_settings_overrides": { + "search_provider": { + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "suggest_url": "__MSG_searchUrl__" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data/remoteIcon.ico b/toolkit/components/search/tests/xpcshell/data/remoteIcon.ico Binary files differnew file mode 100644 index 0000000000..442ab4dc80 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/remoteIcon.ico diff --git a/toolkit/components/search/tests/xpcshell/data/search-legacy.json b/toolkit/components/search/tests/xpcshell/data/search-legacy.json new file mode 100644 index 0000000000..c8416f3813 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/search-legacy.json @@ -0,0 +1,109 @@ +{ + "version": 1, + "buildID": "20121106", + "locale": "en-US", + "metaData": {}, + "engines": [ + { + "_name": "engine1", + "_shortName": "engine1", + "_loadPath": "[other]addEngineWithDetails:engine1@search.mozilla.org", + "description": "A small test engine", + "__searchForm": null, + "_iconURL": "moz-extension://9c38b851-bede-2244-a086-9be8128dd64d/favicon.ico", + "_iconMapObj": { + "{}": "moz-extension://9c38b851-bede-2244-a086-9be8128dd64d/favicon.ico" + }, + "_metaData": { + "alias": "testAlias" + }, + "_urls": [ + { + "template": "https://1.example.com/search", + "rels": [], + "resultDomain": "1.example.com", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + } + ] + } + ], + "_isBuiltin": true, + "queryCharset": "UTF-8", + "extensionID": "engine1@search.mozilla.org" + }, + { + "_name": "engine2", + "_shortName": "engine2", + "_loadPath": "[other]addEngineWithDetails:engine2@search.mozilla.org", + "description": "A small test engine", + "__searchForm": null, + "_iconURL": "moz-extension://0ea1d9b5-a14c-0e42-afaf-f25e8261c135/favicon.ico", + "_iconMapObj": { + "{}": "moz-extension://0ea1d9b5-a14c-0e42-afaf-f25e8261c135/favicon.ico" + }, + "_metaData": { + "alias": null, + "hidden": true + }, + "_urls": [ + { + "template": "https://2.example.com/search", + "rels": [], + "resultDomain": "2.example.com", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + } + ] + } + ], + "_isBuiltin": true, + "queryCharset": "UTF-8", + "extensionID": "engine2@search.mozilla.org" + }, + { + "_name": "Test search engine", + "_shortName": "test-search-engine", + "description": "A test search engine (based on Google search)", + "__searchForm": "http://www.google.com/", + "_iconURL": "data:image/png;base64,AAABAAEAEBAAAAEAGABoAwAAFgAAACgAAAAQAAAAIAAAAAEAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADs9Pt8xetPtu9FsfFNtu%2BTzvb2%2B%2Fne4dFJeBw0egA%2FfAJAfAA8ewBBegAAAAD%2B%2FPtft98Mp%2BwWsfAVsvEbs%2FQeqvF8xO7%2F%2F%2F63yqkxdgM7gwE%2FggM%2BfQA%2BegBDeQDe7PIbotgQufcMufEPtfIPsvAbs%2FQvq%2Bfz%2Bf%2F%2B%2B%2FZKhR05hgBBhQI8hgBAgAI9ewD0%2B%2Fg3pswAtO8Cxf4Kw%2FsJvvYAqupKsNv%2B%2Fv7%2F%2FP5VkSU0iQA7jQA9hgBDgQU%2BfQH%2F%2Ff%2FQ6fM4sM4KsN8AteMCruIqqdbZ7PH8%2Fv%2Fg6Nc%2Fhg05kAA8jAM9iQI%2BhQA%2BgQDQu6b97uv%2F%2F%2F7V8Pqw3eiWz97q8%2Ff%2F%2F%2F%2F7%2FPptpkkqjQE4kwA7kAA5iwI8iAA8hQCOSSKdXjiyflbAkG7u2s%2F%2B%2F%2F39%2F%2F7r8utrqEYtjQE8lgA7kwA7kwA9jwA9igA9hACiWSekVRyeSgiYSBHx6N%2F%2B%2Fv7k7OFRmiYtlAA5lwI7lwI4lAA7kgI9jwE9iwI4iQCoVhWcTxCmb0K%2BooT8%2Fv%2F7%2F%2F%2FJ2r8fdwI1mwA3mQA3mgA8lAE8lAE4jwA9iwE%2BhwGfXifWvqz%2B%2Ff%2F58u%2Fev6Dt4tr%2B%2F%2F2ZuIUsggA7mgM6mAM3lgA5lgA6kQE%2FkwBChwHt4dv%2F%2F%2F728ei1bCi7VAC5XQ7kz7n%2F%2F%2F6bsZkgcB03lQA9lgM7kwA2iQktZToPK4r9%2F%2F%2F9%2F%2F%2FSqYK5UwDKZAS9WALIkFn%2B%2F%2F3%2F%2BP8oKccGGcIRJrERILYFEMwAAuEAAdX%2F%2Ff7%2F%2FP%2B%2BfDvGXQLIZgLEWgLOjlf7%2F%2F%2F%2F%2F%2F9QU90EAPQAAf8DAP0AAfMAAOUDAtr%2F%2F%2F%2F7%2B%2Fu2bCTIYwDPZgDBWQDSr4P%2F%2Fv%2F%2F%2FP5GRuABAPkAA%2FwBAfkDAPAAAesAAN%2F%2F%2B%2Fz%2F%2F%2F64g1C5VwDMYwK8Yg7y5tz8%2Fv%2FV1PYKDOcAAP0DAf4AAf0AAfYEAOwAAuAAAAD%2F%2FPvi28ymXyChTATRrIb8%2F%2F3v8fk6P8MAAdUCAvoAAP0CAP0AAfYAAO4AAACAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAA", + "_metaData": {}, + "_urls": [ + { + "template": "http://suggestqueries.google.com/complete/search?output=firefox&client=firefox&hl={moz:locale}&q={searchTerms}", + "rels": [], + "resultDomain": "suggestqueries.google.com", + "type": "application/x-suggestions+json", + "params": [] + }, + { + "template": "http://www.google.com/search", + "rels": [], + "resultDomain": "google.com", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "channel", + "value": "fflb", + "purpose": "keyword" + }, + { + "name": "channel", + "value": "rcs", + "purpose": "contextmenu" + } + ] + } + ], + "queryCharset": "UTF-8", + "extensionID": "test-addon-id@mozilla.org" + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/data/search-obsolete-distribution.json b/toolkit/components/search/tests/xpcshell/data/search-obsolete-distribution.json new file mode 100644 index 0000000000..575c036698 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/search-obsolete-distribution.json @@ -0,0 +1,43 @@ +{ + "version": 1, + "buildID": "20121106", + "locale": "en-US", + "metaData": {}, + "engines": [ + { + "_name": "engine1", + "_metaData": { + "alias": "testAlias" + }, + "_isAppProvided": true + }, + { + "_name": "engine2", + "_metaData": { + "alias": null, + "hidden": true + }, + "_isAppProvided": true + }, + { + "_name": "Distribution", + "_shortName": "distribution", + "_loadPath": "[distribution]/searchplugins/common/distribution.xml", + "description": "Distribution Search", + "__searchForm": null, + "_metaData": {}, + "_urls": [ + { + "template": "https://example.com/search", + "rels": [ + "searchform" + ], + "resultDomain": "example.com", + "params": [] + } + ], + "queryCharset": "UTF-8", + "_readOnly": false + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/data/search-obsolete-langpack.json b/toolkit/components/search/tests/xpcshell/data/search-obsolete-langpack.json new file mode 100644 index 0000000000..0f376115b1 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/search-obsolete-langpack.json @@ -0,0 +1,99 @@ +{ + "version": 1, + "buildID": "20121106", + "locale": "en-US", + "metaData": {}, + "engines": [ + { + "_name": "engine1", + "_metaData": { + "alias": "testAlias" + }, + "_isAppProvided": true + }, + { + "_name": "engine2", + "_metaData": { + "alias": null, + "hidden": true + }, + "_isAppProvided": true + }, + { + "_name": "Langpack", + "_shortName": "langpack-ru", + "_loadPath": "jar:[app]/extensions/langpack-ru@firefox.mozilla.org.xpi!browser/langpack.xml", + "description": "Langpack search", + "__searchForm": null, + "_metaData": {}, + "_urls": [ + { + "template": "https://example.com/search", + "rels": [ + "searchform" + ], + "resultDomain": "example.com", + "params": [] + } + ], + "queryCharset": "UTF-8" + }, + { + "_name": "Langpack1", + "_shortName": "langpack1-ru", + "_loadPath": "[app]/extensions/langpack-ru@firefox.mozilla.org.xpi!browser/langpack1.xml", + "description": "Langpack1 search", + "__searchForm": null, + "_metaData": {}, + "_urls": [ + { + "template": "https://example1.com/search", + "rels": [ + "searchform" + ], + "resultDomain": "example1.com", + "params": [] + } + ], + "queryCharset": "UTF-8" + }, + { + "_name": "Langpack2", + "_shortName": "langpack2-ru", + "_loadPath": "jar:[profile]/extensions/langpack-ru@firefox.mozilla.org.xpi!browser/langpack2.xml", + "description": "Langpack2 search", + "__searchForm": null, + "_metaData": {}, + "_urls": [ + { + "template": "https://example2.com/search", + "rels": [ + "searchform" + ], + "resultDomain": "example2.com", + "params": [] + } + ], + "queryCharset": "UTF-8" + }, + { + "_name": "Langpack3", + "_shortName": "langpack3-ru", + "_loadPath": "jar:[other]/langpack-ru@firefox.mozilla.org.xpi!browser/langpack3.xml", + "description": "Langpack3 search", + "__searchForm": null, + "_metaData": {}, + "_urls": [ + { + "template": "https://example3.com/search", + "rels": [ + "searchform" + ], + "resultDomain": "example3.com", + "params": [] + } + ], + "queryCharset": "UTF-8" + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/data/search.json b/toolkit/components/search/tests/xpcshell/data/search.json new file mode 100644 index 0000000000..e64c089358 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/search.json @@ -0,0 +1,69 @@ +{ + "version": 1, + "buildID": "20121106", + "locale": "en-US", + "metaData": {}, + "engines": [ + { + "_name": "engine1", + "_metaData": { + "alias": "testAlias" + }, + "_isAppProvided": true + }, + { + "_name": "engine2", + "_metaData": { + "alias": null, + "hidden": true + }, + "_isAppProvided": true + }, + { + "_name": "Test search engine", + "_shortName": "test-search-engine", + "description": "A test search engine (based on Google search)", + "__searchForm": "http://www.google.com/", + "_iconURL": "data:image/png;base64,AAABAAEAEBAAAAEAGABoAwAAFgAAACgAAAAQAAAAIAAAAAEAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADs9Pt8xetPtu9FsfFNtu%2BTzvb2%2B%2Fne4dFJeBw0egA%2FfAJAfAA8ewBBegAAAAD%2B%2FPtft98Mp%2BwWsfAVsvEbs%2FQeqvF8xO7%2F%2F%2F63yqkxdgM7gwE%2FggM%2BfQA%2BegBDeQDe7PIbotgQufcMufEPtfIPsvAbs%2FQvq%2Bfz%2Bf%2F%2B%2B%2FZKhR05hgBBhQI8hgBAgAI9ewD0%2B%2Fg3pswAtO8Cxf4Kw%2FsJvvYAqupKsNv%2B%2Fv7%2F%2FP5VkSU0iQA7jQA9hgBDgQU%2BfQH%2F%2Ff%2FQ6fM4sM4KsN8AteMCruIqqdbZ7PH8%2Fv%2Fg6Nc%2Fhg05kAA8jAM9iQI%2BhQA%2BgQDQu6b97uv%2F%2F%2F7V8Pqw3eiWz97q8%2Ff%2F%2F%2F%2F7%2FPptpkkqjQE4kwA7kAA5iwI8iAA8hQCOSSKdXjiyflbAkG7u2s%2F%2B%2F%2F39%2F%2F7r8utrqEYtjQE8lgA7kwA7kwA9jwA9igA9hACiWSekVRyeSgiYSBHx6N%2F%2B%2Fv7k7OFRmiYtlAA5lwI7lwI4lAA7kgI9jwE9iwI4iQCoVhWcTxCmb0K%2BooT8%2Fv%2F7%2F%2F%2FJ2r8fdwI1mwA3mQA3mgA8lAE8lAE4jwA9iwE%2BhwGfXifWvqz%2B%2Ff%2F58u%2Fev6Dt4tr%2B%2F%2F2ZuIUsggA7mgM6mAM3lgA5lgA6kQE%2FkwBChwHt4dv%2F%2F%2F728ei1bCi7VAC5XQ7kz7n%2F%2F%2F6bsZkgcB03lQA9lgM7kwA2iQktZToPK4r9%2F%2F%2F9%2F%2F%2FSqYK5UwDKZAS9WALIkFn%2B%2F%2F3%2F%2BP8oKccGGcIRJrERILYFEMwAAuEAAdX%2F%2Ff7%2F%2FP%2B%2BfDvGXQLIZgLEWgLOjlf7%2F%2F%2F%2F%2F%2F9QU90EAPQAAf8DAP0AAfMAAOUDAtr%2F%2F%2F%2F7%2B%2Fu2bCTIYwDPZgDBWQDSr4P%2F%2Fv%2F%2F%2FP5GRuABAPkAA%2FwBAfkDAPAAAesAAN%2F%2F%2B%2Fz%2F%2F%2F64g1C5VwDMYwK8Yg7y5tz8%2Fv%2FV1PYKDOcAAP0DAf4AAf0AAfYEAOwAAuAAAAD%2F%2FPvi28ymXyChTATRrIb8%2F%2F3v8fk6P8MAAdUCAvoAAP0CAP0AAfYAAO4AAACAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAA", + "_metaData": {}, + "_urls": [ + { + "template": "http://suggestqueries.google.com/complete/search?output=firefox&client=firefox&hl={moz:locale}&q={searchTerms}", + "rels": [], + "resultDomain": "suggestqueries.google.com", + "type": "application/x-suggestions+json", + "params": [] + }, + { + "template": "http://www.google.com/search", + "rels": [], + "resultDomain": "google.com", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "channel", + "value": "fflb", + "purpose": "searchbar" + }, + { + "name": "channel", + "value": "rcs", + "purpose": "contextmenu" + }, + { + "name": "myparam", + "mozparam": true, + "condition": "pref", + "pref": "test" + } + ] + } + ], + "queryCharset": "UTF-8", + "_extensionID": "test-addon-id@mozilla.org" + } + ] +} 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 0000000000..c6745c61c6 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/searchSuggestions.sjs @@ -0,0 +1,169 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://gre/modules/Timer.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); + +Cu.importGlobalProperties(["TextEncoder"]); + +/** + * 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 convertToUtf8(str) { + return String.fromCharCode(...new TextEncoder().encode(str)); + } + + function writeSuggestions(query, completions = []) { + let jsonString = JSON.stringify([query, completions]); + + // This script must be evaluated as UTF-8 for this to write out the bytes of + // the string in UTF-8. If it's evaluated as Latin-1, the written bytes + // will be the result of UTF-8-encoding the result-string *twice*, which + // will break the "I ❤️" case further down. + let stringOfUtf8Bytes = convertToUtf8(jsonString); + + response.write(stringOfUtf8Bytes); + } + + /** + * Sends `data` as suggestions directly. This is useful when testing rich + * suggestions, which do not conform to the object shape sent by + * writeSuggestions. + * + * @param {array} data + */ + function writeSuggestionsDirectly(data) { + let jsonString = JSON.stringify(data); + let stringOfUtf8Bytes = convertToUtf8(jsonString); + response.setHeader("Content-Type", "application/json", false); + response.write(stringOfUtf8Bytes); + } + + response.setStatusLine(request.httpVersion, 200, "OK"); + + let q = request.method == "GET" ? query.q : undefined; + if (q == "cookie") { + response.setHeader("Set-Cookie", "cookie=1"); + writeSuggestions(q); + } else 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 == "Query Case Mismatch") { + writeSuggestions(q.toUpperCase(), [q]); + } 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("tailjunk ")) { + writeSuggestionsDirectly([ + q, + [q + " normal", q + " tail 1", q + " tail 2"], + [], + { + "google:irrelevantparameter": [], + "google:badformat": { + "google:suggestdetail": [ + {}, + { mp: "… ", t: "tail 1" }, + { mp: "… ", t: "tail 2" }, + ], + }, + }, + ]); + } else if (q && q.startsWith("tailjunk few ")) { + writeSuggestionsDirectly([ + q, + [q + " normal", q + " tail 1", q + " tail 2"], + [], + { + "google:irrelevantparameter": [], + "google:badformat": { + "google:suggestdetail": [{ mp: "… ", t: "tail 1" }], + }, + }, + ]); + } else if (q && q.startsWith("tailalt ")) { + writeSuggestionsDirectly([ + q, + [q + " normal", q + " tail 1", q + " tail 2"], + { + "google:suggestdetail": [ + {}, + { mp: "… ", t: "tail 1" }, + { mp: "… ", t: "tail 2" }, + ], + }, + ]); + } else if (q && q.startsWith("tail ")) { + writeSuggestionsDirectly([ + q, + [q + " normal", q + " tail 1", q + " tail 2"], + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": [ + {}, + { mp: "… ", t: "tail 1" }, + { mp: "… ", t: "tail 2" }, + ], + }, + ]); + } else if (q && q.startsWith("richempty ")) { + writeSuggestionsDirectly([ + q, + [q + " normal", q + " tail 1", q + " tail 2"], + [], + { + "google:irrelevantparameter": [], + "google:suggestdetail": [], + }, + ]); + } 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] = decodeURIComponent(value).replace(/[+]/g, " "); + }); + return query; +} diff --git a/toolkit/components/search/tests/xpcshell/data/search_ignorelist.json b/toolkit/components/search/tests/xpcshell/data/search_ignorelist.json new file mode 100644 index 0000000000..f7dec77fdc --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/search_ignorelist.json @@ -0,0 +1,54 @@ +{ + "version": 1, + "buildID": "20121106", + "locale": "en-US", + "metaData": {}, + "engines": [ + { + "_name": "Test search engine", + "_shortName": "test-search-engine", + "description": "A test search engine (based on Google search)", + "extensionID": "test-addon-id@mozilla.org", + "__searchForm": "http://www.google.com/", + "_iconURL": "data:image/png;base64,AAABAAEAEBAAAAEAGABoAwAAFgAAACgAAAAQAAAAIAAAAAEAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADs9Pt8xetPtu9FsfFNtu%2BTzvb2%2B%2Fne4dFJeBw0egA%2FfAJAfAA8ewBBegAAAAD%2B%2FPtft98Mp%2BwWsfAVsvEbs%2FQeqvF8xO7%2F%2F%2F63yqkxdgM7gwE%2FggM%2BfQA%2BegBDeQDe7PIbotgQufcMufEPtfIPsvAbs%2FQvq%2Bfz%2Bf%2F%2B%2B%2FZKhR05hgBBhQI8hgBAgAI9ewD0%2B%2Fg3pswAtO8Cxf4Kw%2FsJvvYAqupKsNv%2B%2Fv7%2F%2FP5VkSU0iQA7jQA9hgBDgQU%2BfQH%2F%2Ff%2FQ6fM4sM4KsN8AteMCruIqqdbZ7PH8%2Fv%2Fg6Nc%2Fhg05kAA8jAM9iQI%2BhQA%2BgQDQu6b97uv%2F%2F%2F7V8Pqw3eiWz97q8%2Ff%2F%2F%2F%2F7%2FPptpkkqjQE4kwA7kAA5iwI8iAA8hQCOSSKdXjiyflbAkG7u2s%2F%2B%2F%2F39%2F%2F7r8utrqEYtjQE8lgA7kwA7kwA9jwA9igA9hACiWSekVRyeSgiYSBHx6N%2F%2B%2Fv7k7OFRmiYtlAA5lwI7lwI4lAA7kgI9jwE9iwI4iQCoVhWcTxCmb0K%2BooT8%2Fv%2F7%2F%2F%2FJ2r8fdwI1mwA3mQA3mgA8lAE8lAE4jwA9iwE%2BhwGfXifWvqz%2B%2Ff%2F58u%2Fev6Dt4tr%2B%2F%2F2ZuIUsggA7mgM6mAM3lgA5lgA6kQE%2FkwBChwHt4dv%2F%2F%2F728ei1bCi7VAC5XQ7kz7n%2F%2F%2F6bsZkgcB03lQA9lgM7kwA2iQktZToPK4r9%2F%2F%2F9%2F%2F%2FSqYK5UwDKZAS9WALIkFn%2B%2F%2F3%2F%2BP8oKccGGcIRJrERILYFEMwAAuEAAdX%2F%2Ff7%2F%2FP%2B%2BfDvGXQLIZgLEWgLOjlf7%2F%2F%2F%2F%2F%2F9QU90EAPQAAf8DAP0AAfMAAOUDAtr%2F%2F%2F%2F7%2B%2Fu2bCTIYwDPZgDBWQDSr4P%2F%2Fv%2F%2F%2FP5GRuABAPkAA%2FwBAfkDAPAAAesAAN%2F%2F%2B%2Fz%2F%2F%2F64g1C5VwDMYwK8Yg7y5tz8%2Fv%2FV1PYKDOcAAP0DAf4AAf0AAfYEAOwAAuAAAAD%2F%2FPvi28ymXyChTATRrIb8%2F%2F3v8fk6P8MAAdUCAvoAAP0CAP0AAfYAAO4AAACAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAA", + "_metaData": {}, + "_urls": [ + { + "template": "http://suggestqueries.google.com/complete/search?output=firefox&client=firefox&hl={moz:locale}&q={searchTerms}", + "rels": [ + ], + "type": "application/x-suggestions+json", + "params": [ + ] + }, + { + "template": "http://www.google.com/search", + "resultDomain": "google.com", + "rels": [ + ], + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "ignore", + "value": "true" + }, + { + "name": "channel", + "value": "fflb", + "purpose": "keyword" + }, + { + "name": "channel", + "value": "rcs", + "purpose": "contextmenu" + } + ] + } + ], + "queryCharset": "UTF-8" + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/data/svgIcon.svg b/toolkit/components/search/tests/xpcshell/data/svgIcon.svg new file mode 100644 index 0000000000..e2550f8d5d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data/svgIcon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" + width="16" height="16" viewBox="0 0 16 16"> + <rect x="4" y="4" width="8px" height="8px" style="fill: blue" /> +</svg> diff --git a/toolkit/components/search/tests/xpcshell/data1/engine1/manifest.json b/toolkit/components/search/tests/xpcshell/data1/engine1/manifest.json new file mode 100644 index 0000000000..e30a128de1 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data1/engine1/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "engine1", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine1@search.mozilla.org" + } + }, + "hidden": true, + "description": "A small test engine", + "icons": { + "16": "favicon.ico" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "engine1", + "search_url": "https://1.example.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data1/engine2/manifest.json b/toolkit/components/search/tests/xpcshell/data1/engine2/manifest.json new file mode 100644 index 0000000000..c69472ffcb --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data1/engine2/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "engine2", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "engine2@search.mozilla.org" + } + }, + "hidden": true, + "description": "A small test engine", + "icons": { + "16": "favicon.ico" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "engine2", + "search_url": "https://2.example.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + } + ] + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/data1/engine3.xml b/toolkit/components/search/tests/xpcshell/data1/engine3.xml new file mode 100644 index 0000000000..3bd145addb --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data1/engine3.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>engine3</ShortName> +<Description>A small test engine3</Description> +<InputEncoding>UTF-8</InputEncoding> +<Image width="16" height="16">data:image/png;base64,AAABAAEAEBAAAAEAGABoAwAAFgAAACgAAAAQAAAAIAAAAAEAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADs9Pt8xetPtu9FsfFNtu%2BTzvb2%2B%2Fne4dFJeBw0egA%2FfAJAfAA8ewBBegAAAAD%2B%2FPtft98Mp%2BwWsfAVsvEbs%2FQeqvF8xO7%2F%2F%2F63yqkxdgM7gwE%2FggM%2BfQA%2BegBDeQDe7PIbotgQufcMufEPtfIPsvAbs%2FQvq%2Bfz%2Bf%2F%2B%2B%2FZKhR05hgBBhQI8hgBAgAI9ewD0%2B%2Fg3pswAtO8Cxf4Kw%2FsJvvYAqupKsNv%2B%2Fv7%2F%2FP5VkSU0iQA7jQA9hgBDgQU%2BfQH%2F%2Ff%2FQ6fM4sM4KsN8AteMCruIqqdbZ7PH8%2Fv%2Fg6Nc%2Fhg05kAA8jAM9iQI%2BhQA%2BgQDQu6b97uv%2F%2F%2F7V8Pqw3eiWz97q8%2Ff%2F%2F%2F%2F7%2FPptpkkqjQE4kwA7kAA5iwI8iAA8hQCOSSKdXjiyflbAkG7u2s%2F%2B%2F%2F39%2F%2F7r8utrqEYtjQE8lgA7kwA7kwA9jwA9igA9hACiWSekVRyeSgiYSBHx6N%2F%2B%2Fv7k7OFRmiYtlAA5lwI7lwI4lAA7kgI9jwE9iwI4iQCoVhWcTxCmb0K%2BooT8%2Fv%2F7%2F%2F%2FJ2r8fdwI1mwA3mQA3mgA8lAE8lAE4jwA9iwE%2BhwGfXifWvqz%2B%2Ff%2F58u%2Fev6Dt4tr%2B%2F%2F2ZuIUsggA7mgM6mAM3lgA5lgA6kQE%2FkwBChwHt4dv%2F%2F%2F728ei1bCi7VAC5XQ7kz7n%2F%2F%2F6bsZkgcB03lQA9lgM7kwA2iQktZToPK4r9%2F%2F%2F9%2F%2F%2FSqYK5UwDKZAS9WALIkFn%2B%2F%2F3%2F%2BP8oKccGGcIRJrERILYFEMwAAuEAAdX%2F%2Ff7%2F%2FP%2B%2BfDvGXQLIZgLEWgLOjlf7%2F%2F%2F%2F%2F%2F9QU90EAPQAAf8DAP0AAfMAAOUDAtr%2F%2F%2F%2F7%2B%2Fu2bCTIYwDPZgDBWQDSr4P%2F%2Fv%2F%2F%2FP5GRuABAPkAA%2FwBAfkDAPAAAesAAN%2F%2F%2B%2Fz%2F%2F%2F64g1C5VwDMYwK8Yg7y5tz8%2Fv%2FV1PYKDOcAAP0DAf4AAf0AAfYEAOwAAuAAAAD%2F%2FPvi28ymXyChTATRrIb8%2F%2F3v8fk6P8MAAdUCAvoAAP0CAP0AAfYAAO4AAACAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAA</Image> +<Url type="text/html" method="GET" template="https://distro.example.com/search"> + <Param name="q" value="{searchTerms}"/> +</Url> +<SearchForm>https://distro.example.com/</SearchForm> +</SearchPlugin> diff --git a/toolkit/components/search/tests/xpcshell/data1/engines.json b/toolkit/components/search/tests/xpcshell/data1/engines.json new file mode 100644 index 0000000000..957abd1d0c --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/data1/engines.json @@ -0,0 +1,24 @@ +{ + "data": [ + { + "webExtension": { + "id": "engine1@search.mozilla.org" + }, + "orderHint": 10000, + "appliesTo": [{ + "included": { "everywhere": true }, + "default": "yes" + }] + }, + { + "webExtension": { + "id": "engine2@search.mozilla.org" + }, + "orderHint": 7000, + "appliesTo": [{ + "included": { "everywhere": true }, + "default": "no" + }] + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/head_search.js b/toolkit/components/search/tests/xpcshell/head_search.js new file mode 100644 index 0000000000..f903eea2d4 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/head_search.js @@ -0,0 +1,432 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim:set ts=2 sw=2 sts=2 et: */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + FileUtils: "resource://gre/modules/FileUtils.jsm", + PromiseUtils: "resource://gre/modules/PromiseUtils.jsm", + Region: "resource://gre/modules/Region.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + RemoteSettingsClient: "resource://services-settings/RemoteSettingsClient.jsm", + SearchSettings: "resource://gre/modules/SearchSettings.jsm", + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", + SearchService: "resource://gre/modules/SearchService.jsm", + SearchTestUtils: "resource://testing-common/SearchTestUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + setTimeout: "resource://gre/modules/Timer.jsm", + TestUtils: "resource://testing-common/TestUtils.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + sinon: "resource://testing-common/Sinon.jsm", +}); + +var { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +var { AddonTestUtils } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); +const { ExtensionTestUtils } = ChromeUtils.import( + "resource://testing-common/ExtensionXPCShellUtils.jsm" +); + +SearchTestUtils.init(this); + +const SETTINGS_FILENAME = "search.json.mozlz4"; + +// nsSearchService.js uses Services.appinfo.name to build a salt for a hash. +// eslint-disable-next-line mozilla/use-services +var XULRuntime = Cc["@mozilla.org/xre/runtime;1"].getService(Ci.nsIXULRuntime); + +// Expand the amount of information available in error logs +Services.prefs.setBoolPref("browser.search.log", true); +Services.prefs.setBoolPref("browser.region.log", true); + +Services.prefs.setBoolPref("browser.search.modernConfig", true); + +AddonTestUtils.init(this, false); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +// Allow telemetry probes which may otherwise be disabled for some applications (e.g. Thunderbird) +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +// For tests, allow the settings to write sooner than it would do normally so that +// the tests that need to wait for it can run a bit faster. +SearchSettings.SETTNGS_INVALIDATION_DELAY = 250; + +async function promiseSettingsData() { + let path = OS.Path.join(OS.Constants.Path.profileDir, SETTINGS_FILENAME); + let bytes = await OS.File.read(path, { compression: "lz4" }); + return JSON.parse(new TextDecoder().decode(bytes)); +} + +function promiseSaveSettingsData(data) { + return OS.File.writeAtomic( + OS.Path.join(OS.Constants.Path.profileDir, SETTINGS_FILENAME), + new TextEncoder().encode(JSON.stringify(data)), + { compression: "lz4" } + ); +} + +async function promiseEngineMetadata() { + let settings = await promiseSettingsData(); + let data = {}; + for (let engine of settings.engines) { + data[engine._name] = engine._metaData; + } + return data; +} + +async function promiseGlobalMetadata() { + return (await promiseSettingsData()).metaData; +} + +async function promiseSaveGlobalMetadata(globalData) { + let data = await promiseSettingsData(); + data.metaData = globalData; + await promiseSaveSettingsData(data); +} + +function promiseDefaultNotification(type = "normal") { + return SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE[ + type == "private" ? "DEFAULT_PRIVATE" : "DEFAULT" + ], + SearchUtils.TOPIC_ENGINE_MODIFIED + ); +} + +/** + * Clean the profile of any settings file left from a previous run. + * + * @returns {boolean} + * Indicates if the settings file existed. + */ +function removeSettingsFile() { + let file = do_get_profile().clone(); + file.append(SETTINGS_FILENAME); + if (file.exists()) { + file.remove(false); + return true; + } + return false; +} + +/** + * isUSTimezone taken from nsSearchService.js + * + * @returns {boolean} + */ +function isUSTimezone() { + // Timezone assumptions! We assume that if the system clock's timezone is + // between Newfoundland and Hawaii, that the user is in North America. + + // This includes all of South America as well, but we have relatively few + // en-US users there, so that's OK. + + // 150 minutes = 2.5 hours (UTC-2.5), which is + // Newfoundland Daylight Time (http://www.timeanddate.com/time/zones/ndt) + + // 600 minutes = 10 hours (UTC-10), which is + // Hawaii-Aleutian Standard Time (http://www.timeanddate.com/time/zones/hast) + + let UTCOffset = new Date().getTimezoneOffset(); + return UTCOffset >= 150 && UTCOffset <= 600; +} + +const kTestEngineName = "Test search engine"; + +/** + * Waits for the settings file to be saved. + * @returns {Promise} Resolved when the settings file is saved. + */ +function promiseAfterSettings() { + return SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); +} + +/** + * Sets the home region, and waits for the search service to reload the engines. + * + * @param {string} region + * The region to set. + */ +async function promiseSetHomeRegion(region) { + let promise = SearchTestUtils.promiseSearchNotification("engines-reloaded"); + Region._setHomeRegion(region); + await promise; +} + +/** + * Sets the requested/available locales and waits for the search service to + * reload the engines. + * + * @param {string} locale + * The locale to set. + */ +async function promiseSetLocale(locale) { + if (!Services.locale.availableLocales.includes(locale)) { + throw new Error( + `"${locale}" needs to be included in Services.locales.availableLocales at the start of the test.` + ); + } + + let promise = SearchTestUtils.promiseSearchNotification("engines-reloaded"); + Services.locale.requestedLocales = [locale]; + await promise; +} + +/** + * Read a JSON file and return the JS object + * + * @param {nsIFile} file + * The file to read. + * @returns {object} + * Returns the JSON object if the file was successfully read, + * false otherwise. + */ +async function readJSONFile(file) { + let bytes = await OS.File.read(file.path); + return JSON.parse(new TextDecoder().decode(bytes)); +} + +/** + * Recursively compare two objects and check that every property of expectedObj has the same value + * on actualObj. + * + * @param {object} expectedObj + * @param {object} actualObj + * @param {function} skipProp + * A function that is called with the property name and its value, to see if + * testing that property should be skipped or not. + */ +function isSubObjectOf(expectedObj, actualObj, skipProp) { + for (let prop in expectedObj) { + if (skipProp && skipProp(prop, expectedObj[prop])) { + continue; + } + if (expectedObj[prop] instanceof Object) { + Assert.equal( + actualObj[prop]?.length, + expectedObj[prop].length, + `Should have the correct length for property ${prop}` + ); + isSubObjectOf(expectedObj[prop], actualObj[prop], skipProp); + } else { + Assert.equal( + actualObj[prop], + expectedObj[prop], + `Should have the correct value for property ${prop}` + ); + } + } +} + +/** + * After useHttpServer() is called, this string contains the URL of the "data" + * directory, including the final slash. + */ +var gDataUrl; + +/** + * Initializes the HTTP server and ensures that it is terminated when tests end. + * + * @param {string} dir + * The test sub-directory to use for the engines. + * @returns {HttpServer} + * The HttpServer object in case further customization is needed. + */ +function useHttpServer(dir = "data") { + let httpServer = new HttpServer(); + httpServer.start(-1); + httpServer.registerDirectory("/", do_get_cwd()); + gDataUrl = `http://localhost:${httpServer.identity.primaryPort}/${dir}/`; + registerCleanupFunction(async function cleanup_httpServer() { + await new Promise(resolve => { + httpServer.stop(resolve); + }); + }); + return httpServer; +} + +/** + * Adds test engines and returns a promise resolved when they are installed. + * + * The engines are added in the given order. + * + * @param {Array<object>} aItems + * Array of objects with the following properties: + * { + * name: Engine name, used to wait for it to be loaded. + * xmlFileName: Name of the XML file in the "data" folder. + * details: Object containing the parameters of addEngineWithDetails, + * except for the engine name. Alternative to xmlFileName. + * } + */ +var addTestEngines = async function(aItems) { + if (!gDataUrl) { + do_throw("useHttpServer must be called before addTestEngines."); + } + + let engines = []; + + for (let item of aItems) { + info("Adding engine: " + item.name); + await new Promise((resolve, reject) => { + Services.obs.addObserver(function obs(subject, topic, data) { + try { + let engine = subject.QueryInterface(Ci.nsISearchEngine); + info("Observed " + data + " for " + engine.name); + if (data != "engine-added" || engine.name != item.name) { + return; + } + + Services.obs.removeObserver(obs, "browser-search-engine-modified"); + engines.push(engine); + resolve(); + } catch (ex) { + reject(ex); + } + }, "browser-search-engine-modified"); + + if (item.xmlFileName) { + Services.search.addOpenSearchEngine(gDataUrl + item.xmlFileName, null); + } else { + Services.search.addEngineWithDetails(item.name, item.details); + } + }); + } + + return engines; +}; + +/** + * Installs a test engine into the test profile. + * + * @returns {Array<SearchEngine>} + */ +function installTestEngine() { + useHttpServer(); + return addTestEngines([{ name: kTestEngineName, xmlFileName: "engine.xml" }]); +} + +// This "enum" from nsSearchService.js +const TELEMETRY_RESULT_ENUM = { + SUCCESS: 0, + SUCCESS_WITHOUT_DATA: 1, + TIMEOUT: 2, + ERROR: 3, +}; + +/** + * Checks the value of the SEARCH_SERVICE_COUNTRY_FETCH_RESULT probe. + * + * @param {string|null} aExpectedValue + * If a value from TELEMETRY_RESULT_ENUM, we expect to see this value + * recorded exactly once in the probe. If |null|, we expect to see + * nothing recorded in the probe at all. + */ +function checkCountryResultTelemetry(aExpectedValue) { + let histogram = Services.telemetry.getHistogramById( + "SEARCH_SERVICE_COUNTRY_FETCH_RESULT" + ); + let snapshot = histogram.snapshot(); + if (aExpectedValue != null) { + equal(snapshot.values[aExpectedValue], 1); + } else { + deepEqual(snapshot.values, {}); + } +} + +/** + * Provides a basic set of remote settings for use in tests. + */ +async function setupRemoteSettings() { + const settings = await RemoteSettings("hijack-blocklists"); + sinon.stub(settings, "get").returns([ + { + id: "load-paths", + matches: ["[other]addEngineWithDetails:searchignore@mozilla.com"], + _status: "synced", + }, + { + id: "submission-urls", + matches: ["ignore=true"], + _status: "synced", + }, + ]); +} + +/** + * Helper function that sets up a server and respnds to region + * fetch requests. + * @param {string} region + * The region that the server will respond with. + * @param {Promise|null} waitToRespond + * A promise that the server will await on to delay responding + * to the request. + */ +function useCustomGeoServer(region, waitToRespond = Promise.resolve()) { + let srv = useHttpServer(); + srv.registerPathHandler("/fetch_region", async (req, res) => { + res.processAsync(); + await waitToRespond; + res.setStatusLine("1.1", 200, "OK"); + res.write(JSON.stringify({ country_code: region })); + res.finish(); + }); + + Services.prefs.setCharPref( + "browser.region.network.url", + `http://localhost:${srv.identity.primaryPort}/fetch_region` + ); +} + +/** + * Some tests might trigger initialisation which will trigger the search settings + * update. We need to make sure we wait for that to finish before we exit, otherwise + * it may cause shutdown issues. + */ +let updatePromise = SearchTestUtils.promiseSearchNotification( + "settings-update-complete" +); + +registerCleanupFunction(async () => { + if (Services.search.isInitialized) { + await updatePromise; + } +}); + +let consoleAllowList = [ + 'property "localProfileDir" is non-configurable and can\'t be deleted', + 'property "profileDir" is non-configurable and can\'t be deleted', +]; + +let consoleListener = { + observe(subject, topic, data) { + let msg = subject.wrappedJSObject; + let messageContents = msg.arguments[0]?.message || msg.arguments[0]; + if ( + msg.level == "error" && + !consoleAllowList.some(e => messageContents.includes(e)) + ) { + Assert.ok(false, "Unexpected console message: " + messageContents); + } + }, +}; + +Services.obs.addObserver(consoleListener, "console-api-log-event"); + +registerCleanupFunction(async () => { + Services.obs.removeObserver(consoleListener, "console-api-log-event"); +}); diff --git a/toolkit/components/search/tests/xpcshell/method-extensions/engines.json b/toolkit/components/search/tests/xpcshell/method-extensions/engines.json new file mode 100644 index 0000000000..00bb80e7d1 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/method-extensions/engines.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "webExtension": { + "id": "get@search.mozilla.org" + }, + "params": { + "searchUrlGetParams": [ + { "name": "config", "value": "1" }, + { "name": "search", "value": "{searchTerms}" } + ], + "suggestUrlGetParams": [ + { "name": "config", "value": "1" }, + { "name": "suggest", "value": "{searchTerms}" } + ] + }, + "appliesTo": [{ + "included": { "everywhere": true }, + "default": "yes" + }] + }, + { + "webExtension": { + "id": "post@search.mozilla.org" + }, + "params": { + "searchUrlPostParams": [ + { "name": "config", "value": "1" }, + { "name": "search", "value": "{searchTerms}" } + ], + "suggestUrlPostParams": [ + { "name": "config", "value": "1" }, + { "name": "suggest", "value": "{searchTerms}" } + ] + }, + "appliesTo": [{ + "included": { "everywhere": true } + }] + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/method-extensions/get/manifest.json b/toolkit/components/search/tests/xpcshell/method-extensions/get/manifest.json new file mode 100644 index 0000000000..238174ec5f --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/method-extensions/get/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Get Engine", + "manifest_version": 2, + "version": "1.0", + "description": "Get engine to test get params", + "applications": { + "gecko": { + "id": "get@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "Get Engine", + "search_url": "https://example.com", + "search_url_get_params": "webExtension=1&search={searchTerms}", + "suggest_url": "https://example.com", + "suggest_url_get_params": "webExtension=1&suggest={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/method-extensions/post/manifest.json b/toolkit/components/search/tests/xpcshell/method-extensions/post/manifest.json new file mode 100644 index 0000000000..093af555bc --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/method-extensions/post/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Post Engine", + "manifest_version": 2, + "version": "1.0", + "description": "Get engine to test ost params", + "applications": { + "gecko": { + "id": "post@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "Post Engine", + "search_url": "https://example.com", + "search_url_post_params": "webExtension=1&search={searchTerms}", + "suggest_url": "https://example.com", + "suggest_url_post_params": "webExtension=1&suggest={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/opensearch/invalid.xml b/toolkit/components/search/tests/xpcshell/opensearch/invalid.xml new file mode 100644 index 0000000000..e8efce6726 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/opensearch/invalid.xml @@ -0,0 +1 @@ +# An invalid xml engine file. diff --git a/toolkit/components/search/tests/xpcshell/opensearch/mozilla-ns.xml b/toolkit/components/search/tests/xpcshell/opensearch/mozilla-ns.xml new file mode 100644 index 0000000000..f185f94868 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/opensearch/mozilla-ns.xml @@ -0,0 +1,12 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>mozilla-ns</ShortName> +<Description>An engine using mozilla namespace</Description> +<InputEncoding>UTF-8</InputEncoding> +<Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC</Image> +<Url type="text/html" method="GET" template="https://example.com/search"> + <Param name="q" value="{searchTerms}"/> + <MozParam name="channel" condition="purpose" purpose="searchbar" value="test"/> +</Url> +<SearchForm>https://example.com/</SearchForm> +</SearchPlugin> diff --git a/toolkit/components/search/tests/xpcshell/opensearch/post.xml b/toolkit/components/search/tests/xpcshell/opensearch/post.xml new file mode 100644 index 0000000000..621e49c872 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/opensearch/post.xml @@ -0,0 +1,8 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"> + <ShortName>Post</ShortName> + <Url type="text/html" method="POST" template="https://example.com/post"> + <Param name="searchterms" value="{searchTerms}"/> + </Url> + <Url type="text/html" method="POST" template="http://engine-rel-searchform-post.xml/POST" rel="searchform"/> + <SearchForm>http://engine-rel-searchform-post.xml/?search</SearchForm> +</OpenSearchDescription> diff --git a/toolkit/components/search/tests/xpcshell/opensearch/simple.xml b/toolkit/components/search/tests/xpcshell/opensearch/simple.xml new file mode 100644 index 0000000000..ee38e51bca --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/opensearch/simple.xml @@ -0,0 +1,11 @@ +<?xml version="1.0" encoding="UTF-8"?> +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/"> +<ShortName>simple</ShortName> +<Description>A small test engine</Description> +<InputEncoding>UTF-8</InputEncoding> +<Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC</Image> +<Url type="text/html" method="GET" template="https://example.com/search"> + <Param name="q" value="{searchTerms}"/> +</Url> +<SearchForm>https://example.com/</SearchForm> +</OpenSearchDescription> diff --git a/toolkit/components/search/tests/xpcshell/opensearch/suggestion-alternate.xml b/toolkit/components/search/tests/xpcshell/opensearch/suggestion-alternate.xml new file mode 100644 index 0000000000..7a961520b9 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/opensearch/suggestion-alternate.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearchdescription/1.1/"> +<ShortName>suggestion-alternate</ShortName> +<Description>A small engine with suggestions</Description> +<InputEncoding>UTF-8</InputEncoding> +<Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC</Image> +<Url type="text/html" method="GET" template="https://example.com/search"> + <Param name="q" value="{searchTerms}"/> +</Url> +<Url type="application/json" rel="suggestions" method="GET" + template="https://example.com/suggest"> + <Param name="suggestion" value="{searchTerms}"/> +</Url> + +<SearchForm>https://example.com/</SearchForm> +</OpenSearchDescription> diff --git a/toolkit/components/search/tests/xpcshell/opensearch/suggestion.xml b/toolkit/components/search/tests/xpcshell/opensearch/suggestion.xml new file mode 100644 index 0000000000..8d2f701a36 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/opensearch/suggestion.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="UTF-8"?> +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearchdescription/1.0/"> +<ShortName>suggestion</ShortName> +<Description>A small engine with suggestions</Description> +<InputEncoding>windows-1252</InputEncoding> +<Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC</Image> +<Url type="text/html" method="GET" template="https://example.com/search"> + <Param name="q" value="{searchTerms}"/> +</Url> +<Url type="application/x-suggestions+json" method="GET" + template="https://example.com/suggest"> + <Param name="suggestion" value="{searchTerms}"/> +</Url> +<Url type="text/html" method="GET" template="http://engine-rel-searchform.xml/?search" rel="searchform"/> +</OpenSearchDescription> diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/head_searchconfig.js b/toolkit/components/search/tests/xpcshell/searchconfigs/head_searchconfig.js new file mode 100644 index 0000000000..173cffdfb5 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/head_searchconfig.js @@ -0,0 +1,599 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AddonTestUtils: "resource://testing-common/AddonTestUtils.jsm", + AppConstants: "resource://gre/modules/AppConstants.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", + OS: "resource://gre/modules/osfile.jsm", + Region: "resource://gre/modules/Region.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + SearchEngine: "resource://gre/modules/SearchEngine.jsm", + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", + SearchTestUtils: "resource://testing-common/SearchTestUtils.jsm", + SearchUtils: "resource://gre/modules/SearchUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + sinon: "resource://testing-common/Sinon.jsm", +}); + +XPCOMUtils.defineLazyServiceGetters(this, { + gEnvironment: ["@mozilla.org/process/environment;1", "nsIEnvironment"], +}); + +XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]); + +const GLOBAL_SCOPE = this; +const TEST_DEBUG = gEnvironment.get("TEST_DEBUG"); + +const URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; +const URLTYPE_SEARCH_HTML = "text/html"; +const SUBMISSION_PURPOSES = [ + "searchbar", + "keyword", + "contextmenu", + "homepage", + "newtab", +]; + +let engineSelector; + +/** + * This function is used to override the remote settings configuration + * if the SEARCH_CONFIG environment variable is set. This allows testing + * against a remote server. + */ +async function maybeSetupConfig() { + const SEARCH_CONFIG = gEnvironment.get("SEARCH_CONFIG"); + if (SEARCH_CONFIG) { + if (!(SEARCH_CONFIG in SearchUtils.ENGINES_URLS)) { + throw new Error(`Invalid value for SEARCH_CONFIG`); + } + const url = SearchUtils.ENGINES_URLS[SEARCH_CONFIG]; + const response = await fetch(url); + const config = await response.json(); + const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + sinon.stub(settings, "get").returns(config.data); + } +} + +/** + * This class implements the test harness for search configuration tests. + * These tests are designed to ensure that the correct search engines are + * loaded for the various region/locale configurations. + * + * The configuration for each test is represented by an object having the + * following properties: + * + * - identifier (string) + * The identifier for the search engine under test. + * - default (object) + * An inclusion/exclusion configuration (see below) to detail when this engine + * should be listed as default. + * + * The inclusion/exclusion configuration is represented as an object having the + * following properties: + * + * - included (array) + * An optional array of region/locale pairs. + * - excluded (array) + * An optional array of region/locale pairs. + * + * If the object is empty, the engine is assumed not to be part of any locale/region + * pair. + * If the object has `excluded` but not `included`, then the engine is assumed to + * be part of every locale/region pair except for where it matches the exclusions. + * + * The region/locale pairs are represented as an object having the following + * properties: + * + * - region (array) + * An array of two-letter region codes. + * - locale (object) + * A locale object which may consist of: + * - matches (array) + * An array of locale strings which should exactly match the locale. + * - startsWith (array) + * An array of locale strings which the locale should start with. + */ +class SearchConfigTest { + /** + * @param {object} config + * The initial configuration for this test, see above. + */ + constructor(config = {}) { + this._config = config; + } + + /** + * Sets up the test. + */ + async setup() { + AddonTestUtils.init(GLOBAL_SCOPE); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" + ); + + await maybeSetupConfig(); + + // Disable region checks. + Services.prefs.setBoolPref("browser.search.geoSpecificDefaults", false); + + // Enable separatePrivateDefault testing. We test with this on, as we have + // separate tests for ensuring the normal = private when this is off. + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + + // We must use the engine selector that the search service has created (if + // it has), as remote settings can only easily deal with us loading the + // configuration once - after that, it tries to access the network. + engineSelector = + Services.search.wrappedJSObject._engineSelector || + new SearchEngineSelector(); + + // Note: we don't use the helper function here, so that we have at least + // one message output per process. + Assert.ok( + Services.search.isInitialized, + "Should have correctly initialized the search service" + ); + } + + /** + * Runs the test. + */ + async run() { + const locales = await this._getLocales(); + const regions = this._regions; + + // We loop on region and then locale, so that we always cause a re-init + // when updating the requested/available locales. + for (let region of regions) { + for (let locale of locales) { + const engines = await this._getEngines(region, locale); + this._assertEngineRules([engines[0]], region, locale, "default"); + const isPresent = this._assertAvailableEngines(region, locale, engines); + if (isPresent) { + this._assertEngineDetails(region, locale, engines); + } + } + } + } + + async _getEngines(region, locale) { + let engines = []; + let configs = await engineSelector.fetchEngineConfiguration({ + locale, + region: region || "default", + channel: AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "esr" + : AppConstants.MOZ_UPDATE_CHANNEL, + }); + for (let config of configs.engines) { + let engine = await Services.search.wrappedJSObject.makeEngineFromConfig( + config + ); + engines.push(engine); + } + return engines; + } + + /** + * @returns {Set} the list of regions for the tests to run with. + */ + get _regions() { + // TODO: The legacy configuration worked with null as an unknown region, + // for the search engine selector, we expect "default" but apply the + // fallback in _getEngines. Once we remove the legacy configuration, we can + // simplify this. + if (TEST_DEBUG) { + return new Set(["by", "cn", "kz", "us", "ru", "tr", null]); + } + return [...Services.intl.getAvailableLocaleDisplayNames("region"), null]; + } + + /** + * @returns {array} the list of locales for the tests to run with. + */ + async _getLocales() { + if (TEST_DEBUG) { + return ["be", "en-US", "kk", "tr", "ru", "zh-CN", "ach", "unknown"]; + } + const data = await OS.File.read(do_get_file("all-locales").path, { + encoding: "utf-8", + }); + // "en-US" is not in all-locales as it is the default locale + // add it manually to ensure it is tested. + let locales = [...data.split("\n").filter(e => e != ""), "en-US"]; + // BCP47 requires all variants are 5-8 characters long. Our + // build sytem uses the short `mac` variant, this is invalid, and inside + // the app we turn it into `ja-JP-macos` + locales = locales.map(l => (l == "ja-JP-mac" ? "ja-JP-macos" : l)); + // The locale sometimes can be unknown or a strange name, e.g. if the updater + // is disabled, it may be "und", add one here so we know what happens if we + // hit it. + locales.push("unknown"); + return locales; + } + + /** + * Determines if a locale matches with a locales section in the configuration. + * + * @param {object} locales + * @param {array} [locales.matches] + * Array of locale names to match exactly. + * @param {array} [locales.startsWith] + * Array of locale names to match the start. + * @param {string} locale + * The two-letter locale code. + * @returns {boolean} + * True if the locale matches. + */ + _localeIncludes(locales, locale) { + if ("matches" in locales && locales.matches.includes(locale)) { + return true; + } + if ("startsWith" in locales) { + return !!locales.startsWith.find(element => locale.startsWith(element)); + } + + return false; + } + + /** + * Determines if a locale/region pair match a section of the configuration. + * + * @param {object} section + * The configuration section to match against. + * @param {string} region + * The two-letter region code. + * @param {string} locale + * The two-letter locale code. + * @returns {boolean} + * True if the locale/region pair matches the section. + */ + _localeRegionInSection(section, region, locale) { + for (const { regions, locales } of section) { + // If we only specify a regions or locales section then + // it is always considered included in the other section. + const inRegions = !regions || regions.includes(region); + const inLocales = !locales || this._localeIncludes(locales, locale); + if (inRegions && inLocales) { + return true; + } + } + return false; + } + + /** + * Helper function to find an engine from within a list. + * + * @param {Array} engines + * The list of engines to check. + * @param {string} identifier + * The identifier to look for in the list. + * @returns {Engine} + * Returns the engine if found, null otherwise. + */ + _findEngine(engines, identifier) { + return engines.find(engine => engine.identifier.startsWith(identifier)); + } + + /** + * Asserts whether the engines rules defined in the configuration are met. + * + * @param {Array} engines + * The list of engines to check. + * @param {string} region + * The two-letter region code. + * @param {string} locale + * The two-letter locale code. + * @param {string} section + * The section of the configuration to check. + * @returns {boolean} + * Returns true if the engine is expected to be present, false otherwise. + */ + _assertEngineRules(engines, region, locale, section) { + const infoString = `region: "${region}" locale: "${locale}"`; + const config = this._config[section]; + const hasIncluded = "included" in config; + const hasExcluded = "excluded" in config; + const identifierIncluded = !!this._findEngine( + engines, + this._config.identifier + ); + + // If there's not included/excluded, then this shouldn't be the default anywhere. + if (section == "default" && !hasIncluded && !hasExcluded) { + this.assertOk( + !identifierIncluded, + `Should not be ${section} for any locale/region, + currently set for ${infoString}` + ); + return false; + } + + // If there's no included section, we assume the engine is default everywhere + // and we should apply the exclusions instead. + let included = + hasIncluded && + this._localeRegionInSection(config.included, region, locale); + + let notExcluded = + hasExcluded && + !this._localeRegionInSection(config.excluded, region, locale); + + if (included || notExcluded) { + this.assertOk( + identifierIncluded, + `Should be ${section} for ${infoString}` + ); + return true; + } + this.assertOk( + !identifierIncluded, + `Should not be ${section} for ${infoString}` + ); + return false; + } + + /** + * Asserts whether the engine is correctly set as default or not. + * + * @param {string} region + * The two-letter region code. + * @param {string} locale + * The two-letter locale code. + */ + _assertDefaultEngines(region, locale) { + this._assertEngineRules( + [Services.search.originalDefaultEngine], + region, + locale, + "default" + ); + // At the moment, this uses the same section as the normal default, as + // we don't set this differently for any region/locale. + this._assertEngineRules( + [Services.search.originalPrivateDefaultEngine], + region, + locale, + "default" + ); + } + + /** + * Asserts whether the engine is correctly available or not. + * + * @param {string} region + * The two-letter region code. + * @param {string} locale + * The two-letter locale code. + * @param {array} engines + * The current visible engines. + * @returns {boolean} + * Returns true if the engine is expected to be present, false otherwise. + */ + _assertAvailableEngines(region, locale, engines) { + return this._assertEngineRules(engines, region, locale, "available"); + } + + /** + * Asserts the engine follows various rules. + * + * @param {string} region + * The two-letter region code. + * @param {string} locale + * The two-letter locale code. + * @param {array} engines + * The current visible engines. + */ + _assertEngineDetails(region, locale, engines) { + const details = this._config.details.filter(value => { + const included = this._localeRegionInSection( + value.included, + region, + locale + ); + const excluded = + value.excluded && + this._localeRegionInSection(value.excluded, region, locale); + return included && !excluded; + }); + this.assertEqual( + details.length, + 1, + `Should have just one details section for region: ${region} locale: ${locale}` + ); + + const engine = this._findEngine(engines, this._config.identifier); + this.assertOk(engine, "Should have an engine present"); + + if (this._config.aliases) { + this.assertDeepEqual( + engine.aliases, + this._config.aliases, + "Should have the correct aliases for the engine" + ); + } + + const location = `in region:${region}, locale:${locale}`; + + for (const rule of details) { + this._assertCorrectDomains(location, engine, rule); + if (rule.codes) { + this._assertCorrectCodes(location, engine, rule); + } + if (rule.searchUrlCode || rule.searchFormUrlCode || rule.suggestUrlCode) { + this._assertCorrectUrlCode(location, engine, rule); + } + if (rule.aliases) { + this.assertDeepEqual( + engine.aliases, + rule.aliases, + "Should have the correct aliases for the engine" + ); + } + if (rule.telemetryId) { + this.assertEqual( + engine.telemetryId, + rule.telemetryId, + `Should have the correct telemetryId ${location}.` + ); + } + } + } + + /** + * Asserts whether the engine is using the correct domains or not. + * + * @param {string} location + * Debug string with locale + region information. + * @param {object} engine + * The engine being tested. + * @param {object} rules + * Rules to test. + */ + _assertCorrectDomains(location, engine, rules) { + this.assertOk( + rules.domain, + `Should have an expectedDomain for the engine ${location}` + ); + + const searchForm = new URL(engine.searchForm); + this.assertOk( + searchForm.host.endsWith(rules.domain), + `Should have the correct search form domain ${location}. + Got "${searchForm.host}", expected to end with "${rules.domain}".` + ); + + let submission = engine.getSubmission("test", URLTYPE_SEARCH_HTML); + + this.assertOk( + submission.uri.host.endsWith(rules.domain), + `Should have the correct domain for type: ${URLTYPE_SEARCH_HTML} ${location}. + Got "${submission.uri.host}", expected to end with "${rules.domain}".` + ); + + submission = engine.getSubmission("test", URLTYPE_SUGGEST_JSON); + if (this._config.noSuggestionsURL || rules.noSuggestionsURL) { + this.assertOk(!submission, "Should not have a submission url"); + } else if (this._config.suggestionUrlBase) { + this.assertEqual( + submission.uri.prePath + submission.uri.filePath, + this._config.suggestionUrlBase, + `Should have the correct domain for type: ${URLTYPE_SUGGEST_JSON} ${location}.` + ); + this.assertOk( + submission.uri.query.includes(rules.suggestUrlCode), + `Should have the code in the uri` + ); + } + } + + /** + * Asserts whether the engine is using the correct codes or not. + * + * @param {string} location + * Debug string with locale + region information. + * @param {object} engine + * The engine being tested. + * @param {object} rules + * Rules to test. + */ + _assertCorrectCodes(location, engine, rules) { + for (const purpose of SUBMISSION_PURPOSES) { + // Don't need to repeat the code if we use it for all purposes. + const code = + typeof rules.codes === "string" ? rules.codes : rules.codes[purpose]; + const submission = engine.getSubmission("test", "text/html", purpose); + const submissionQueryParams = submission.uri.query.split("&"); + this.assertOk( + submissionQueryParams.includes(code), + `Expected "${code}" in url "${submission.uri.spec}" from purpose "${purpose}" ${location}` + ); + + const paramName = code.split("=")[0]; + this.assertOk( + submissionQueryParams.filter(param => param.startsWith(paramName)) + .length == 1, + `Expected only one "${paramName}" parameter in "${submission.uri.spec}" from purpose "${purpose}" ${location}` + ); + } + } + + /** + * Asserts whether the engine is using the correct URL codes or not. + * + * @param {string} location + * Debug string with locale + region information. + * @param {object} engine + * The engine being tested. + * @param {object} rule + * Rules to test. + */ + _assertCorrectUrlCode(location, engine, rule) { + if (rule.searchUrlCode) { + const submission = engine.getSubmission("test", URLTYPE_SEARCH_HTML); + this.assertOk( + submission.uri.query.split("&").includes(rule.searchUrlCode), + `Expected "${rule.searchUrlCode}" in search url "${submission.uri.spec}"` + ); + } + if (rule.searchFormUrlCode) { + const uri = engine.searchForm; + this.assertOk( + uri.includes(rule.searchFormUrlCode), + `Expected "${rule.searchFormUrlCode}" in "${uri}"` + ); + } + if (rule.suggestUrlCode) { + const submission = engine.getSubmission("test", URLTYPE_SUGGEST_JSON); + this.assertOk( + submission.uri.query.split("&").includes(rule.suggestUrlCode), + `Expected "${rule.suggestUrlCode}" in suggestion url "${submission.uri.spec}"` + ); + } + } + + /** + * Helper functions which avoid outputting test results when there are no + * failures. These help the tests to run faster, and avoid clogging up the + * python test runner process. + */ + + assertOk(value, message) { + if (!value || TEST_DEBUG) { + Assert.ok(value, message); + } + } + + assertEqual(actual, expected, message) { + if (actual != expected || TEST_DEBUG) { + Assert.equal(actual, expected, message); + } + } + + assertDeepEqual(actual, expected, message) { + if (!ObjectUtils.deepEqual(actual, expected)) { + Assert.deepEqual(actual, expected, message); + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_amazon.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_amazon.js new file mode 100644 index 0000000000..d6c2af39b2 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_amazon.js @@ -0,0 +1,452 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const test = new SearchConfigTest({ + identifier: "amazon", + default: { + // Not included anywhere. + }, + available: { + included: [ + { + // We don't currently enforce by region, but do locale instead. + // regions: [ + // "at", "au", "be", "ca", "ch", "de", "fr", "gb", "ie", "it", "jp", "nl", + // "us", + // ], + locales: { + matches: [ + "ach", + "af", + "ar", + "az", + "bg", + "bn", + "bn-IN", + "br", + "cak", + "cy", + "da", + "de", + "dsb", + "el", + "eo", + "es-AR", + "eu", + "fa", + "ff", + "fr", + "ga-IE", + "gd", + "gl", + "gn", + "gu-IN", + "hr", + "hsb", + "hy-AM", + "ia", + "is", + "it", + "ja-JP-macos", + "ja", + "ka", + "km", + "kn", + "lij", + "lt", + "mk", + "mr", + "ms", + "my", + "nb-NO", + "nn-NO", + "pa-IN", + "pt-PT", + "ro", + "si", + "son", + "sq", + "sr", + "ta", + "te", + "th", + "tl", + "trs", + "ur", + "uz", + "wo", + "zh-CN", + ], + startsWith: ["en"], + }, + }, + ], + }, + details: [ + { + // Note: These should be based on region, but we don't currently enforce that. + // Note: the order here is important. A region/locale match higher up in the + // list will override a region/locale match lower down. + domain: "amazon.com.au", + telemetryId: "amazon-au", + aliases: ["@amazon"], + included: [ + { + regions: ["au"], + locales: { + matches: [ + "ach", + "af", + "ar", + "az", + "bg", + "bn-IN", + "cak", + "unknown", + "eo", + "en-US", + "es-AR", + "fa", + "gn", + "hy-AM", + "ia", + "is", + "ka", + "km", + "lt", + "mk", + "ms", + "my", + "ro", + "si", + "th", + "tl", + "trs", + "uz", + ], + }, + }, + { + regions: ["au"], + locales: { + matches: [ + "cy", + "da", + "el", + "en-GB", + "eu", + "ga-IE", + "gd", + "gl", + "hr", + "nb-NO", + "nn-NO", + "pt-PT", + "sq", + "sr", + ], + }, + }, + ], + noSuggestionsURL: true, + }, + { + domain: "amazon.ca", + telemetryId: "amazon-ca", + aliases: ["@amazon"], + included: [ + { + locales: { + matches: ["en-CA"], + }, + }, + { + regions: ["ca"], + locales: { + matches: [ + "ach", + "af", + "ar", + "az", + "bg", + "bn-IN", + "cak", + "unknown", + "eo", + "en-US", + "es-AR", + "fa", + "gn", + "hy-AM", + "ia", + "is", + "ka", + "km", + "lt", + "mk", + "ms", + "my", + "ro", + "si", + "th", + "tl", + "trs", + "uz", + ], + }, + }, + { + regions: ["ca"], + locales: { + matches: ["br", "fr", "ff", "son", "wo"], + }, + }, + ], + suggestionUrlBase: "https://completion.amazon.ca/search/complete", + suggestUrlCode: "mkt=7", + }, + { + domain: "amazon.fr", + telemetryId: "amazon-france", + aliases: ["@amazon"], + included: [ + { + locales: { + matches: ["br", "fr", "ff", "son", "wo"], + }, + }, + { + regions: ["fr"], + locales: { + matches: [ + "ach", + "af", + "ar", + "az", + "bg", + "bn-IN", + "cak", + "unknown", + "eo", + "en-US", + "es-AR", + "fa", + "gn", + "hy-AM", + "ia", + "is", + "ka", + "km", + "lt", + "mk", + "ms", + "my", + "ro", + "si", + "th", + "tl", + "trs", + "uz", + ], + }, + }, + ], + excluded: [{ regions: ["ca"] }], + searchUrlCode: "tag=firefox-fr-21", + suggestionUrlBase: "https://completion.amazon.fr/search/complete", + suggestUrlCode: "mkt=5", + }, + { + domain: "amazon.co.uk", + telemetryId: "amazon-en-GB", + aliases: ["@amazon"], + included: [ + { + locales: { + matches: [ + "cy", + "da", + "el", + "en-GB", + "eu", + "ga-IE", + "gd", + "gl", + "hr", + "nb-NO", + "nn-NO", + "pt-PT", + "sq", + "sr", + ], + }, + }, + { + regions: ["gb"], + locales: { + matches: [ + "ach", + "af", + "ar", + "az", + "bg", + "bn-IN", + "cak", + "unknown", + "eo", + "en-US", + "es-AR", + "fa", + "gn", + "hy-AM", + "ia", + "is", + "ka", + "km", + "lt", + "mk", + "ms", + "my", + "ro", + "si", + "th", + "tl", + "trs", + "uz", + ], + }, + }, + ], + excluded: [{ regions: ["au"] }], + searchUrlCode: "tag=firefox-uk-21", + suggestionUrlBase: "https://completion.amazon.co.uk/search/complete", + suggestUrlCode: "mkt=3", + }, + { + domain: "amazon.com", + telemetryId: "amazondotcom", + aliases: ["@amazon"], + included: [ + { + locales: { + matches: [ + "ach", + "af", + "ar", + "az", + "bg", + "bn-IN", + "cak", + "unknown", + "eo", + "en-US", + "es-AR", + "fa", + "gn", + "hy-AM", + "ia", + "is", + "ka", + "km", + "lt", + "mk", + "ms", + "my", + "ro", + "si", + "th", + "tl", + "trs", + "uz", + ], + }, + }, + ], + excluded: [{ regions: ["au", "ca", "fr", "gb"] }], + searchUrlCode: "tag=mozilla-20", + }, + { + domain: "amazon.cn", + telemetryId: "amazondotcn", + included: [ + { + locales: { + matches: ["zh-CN"], + }, + }, + ], + searchUrlCode: "ix=sunray", + noSuggestionsURL: true, + }, + { + domain: "amazon.co.jp", + telemetryId: "amazon-jp", + aliases: ["@amazon"], + included: [ + { + locales: { + startsWith: ["ja"], + }, + }, + ], + searchUrlCode: "tag=mozillajapan-fx-22", + suggestionUrlBase: "https://completion.amazon.co.jp/search/complete", + suggestUrlCode: "mkt=6", + }, + { + domain: "amazon.de", + telemetryId: "amazon-de", + aliases: ["@amazon"], + included: [ + { + locales: { + matches: ["de", "dsb", "hsb"], + }, + }, + ], + searchUrlCode: "tag=firefox-de-21", + suggestionUrlBase: "https://completion.amazon.de/search/complete", + suggestUrlCode: "mkt=4", + }, + { + domain: "amazon.in", + telemetryId: "amazon-in", + aliases: ["@amazon"], + included: [ + { + locales: { + matches: ["bn", "gu-IN", "kn", "mr", "pa-IN", "ta", "te", "ur"], + }, + }, + ], + suggestionUrlBase: "https://completion.amazon.in/search/complete", + suggestUrlCode: "mkt=44571", + }, + { + domain: "amazon.it", + telemetryId: "amazon-it", + aliases: ["@amazon"], + included: [ + { + locales: { + matches: ["it", "lij"], + }, + }, + ], + searchUrlCode: "tag=firefoxit-21", + suggestionUrlBase: "https://completion.amazon.it/search/complete", + suggestUrlCode: "mkt=35691", + }, + ], +}); + +add_task(async function setup() { + // We only need to do setup on one of the tests. + await test.setup(); +}); + +add_task(async function test_searchConfig_amazon() { + await test.run(); +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_baidu.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_baidu.js new file mode 100644 index 0000000000..95f8559920 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_baidu.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const test = new SearchConfigTest({ + identifier: "baidu", + aliases: ["@\u767E\u5EA6", "@baidu"], + default: { + included: [ + { + regions: ["cn"], + locales: { + matches: ["zh-CN"], + }, + }, + ], + }, + available: { + included: [ + { + locales: { + matches: ["zh-CN"], + }, + }, + ], + }, + details: [ + { + included: [{}], + domain: "baidu.com", + telemetryId: "baidu", + searchUrlCode: "tn=monline_7_dg", + suggestUrlCode: "tn=monline_7_dg", + }, + ], +}); + +add_task(async function setup() { + await test.setup(); +}); + +add_task(async function test_searchConfig_baidu() { + await test.run(); +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_bing.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_bing.js new file mode 100644 index 0000000000..a317231f2b --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_bing.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const test = new SearchConfigTest({ + identifier: "bing", + aliases: ["@bing"], + default: { + // Not included anywhere. + }, + available: { + included: [ + { + // regions: [ + // These arent currently enforced. + // "au", "at", "be", "br", "ca", "fi", "fr", "de", + // "in", "ie", "it", "jp", "my", "mx", "nl", "nz", + // "no", "sg", "es", "se", "ch", "gb", "us", + // ], + locales: { + matches: [ + "ach", + "af", + "an", + "ar", + "ast", + "az", + "ca", + "ca-valencia", + "cak", + "da", + "de", + "dsb", + "el", + "eo", + "es-CL", + "es-ES", + "es-MX", + "eu", + "fa", + "ff", + "fi", + "fr", + "fy-NL", + "gn", + "gu-IN", + "hi-IN", + "hr", + "hsb", + "ia", + "is", + "it", + "ja-JP-macos", + "ja", + "ka", + "kab", + "km", + "kn", + "lij", + "lo", + "lt", + "mk", + "ms", + "my", + "nb-NO", + "ne-NP", + "nl", + "nn-NO", + "oc", + "pa-IN", + "pt-BR", + "rm", + "ro", + "son", + "sq", + "sr", + "sv-SE", + "th", + "tl", + "trs", + "uk", + "ur", + "uz", + "wo", + "xh", + "zh-CN", + ], + startsWith: ["bn", "en"], + }, + }, + ], + }, + details: [ + { + included: [{}], + domain: "bing.com", + telemetryId: "bing", + codes: { + searchbar: "form=MOZSBR", + keyword: "form=MOZLBR", + contextmenu: "form=MOZCON", + homepage: "form=MOZSPG", + newtab: "form=MOZTSB", + }, + searchUrlCode: "pc=MOZI", + searchFormUrlCode: "pc=MOZI", + }, + ], +}); + +add_task(async function setup() { + await test.setup(); +}); + +add_task(async function test_searchConfig_bing() { + await test.run(); +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_distributions.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_distributions.js new file mode 100644 index 0000000000..7330fc5ea0 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_distributions.js @@ -0,0 +1,766 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", + SearchService: "resource://gre/modules/SearchService.jsm", +}); + +const tests = []; + +// Bing should be default everywhere for Acer +for (let [locale, region] of [ + ["en-US", "US"], + ["pl", "PL"], + ["be", "BY"], + ["ru", "RU"], + ["zh-CN", "CN"], +]) { + tests.push({ + distribution: "acer-001", + locale, + region, + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MOZD") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + distribution: "acer-002", + locale, + region, + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MOZD") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + distribution: "acer-g-003", + locale, + region, + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MOZE") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); +} + +for (let canonicalId of ["canonical", "canonical-001", "canonical-002"]) { + tests.push({ + locale: "en-US", + region: "US", + distribution: canonicalId, + test: engines => + hasParams(engines, "Google", "searchbar", "client=ubuntu") && + hasParams(engines, "Google", "searchbar", "channel=fs") && + hasTelemetryId(engines, "Google", "google-canonical"), + }); + + tests.push({ + locale: "en-US", + region: "GB", + distribution: canonicalId, + test: engines => + hasParams(engines, "Google", "searchbar", "client=ubuntu") && + hasParams(engines, "Google", "searchbar", "channel=fs") && + hasTelemetryId(engines, "Google", "google-canonical"), + }); + + tests.push({ + distribution: canonicalId, + test: engines => + hasParams(engines, "DuckDuckGo", "searchbar", "t=canonical"), + }); + + tests.push({ + locale: "en-US", + region: "US", + distribution: canonicalId, + test: engines => + hasParams(engines, "Amazon.com", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "de", + region: "DE", + distribution: canonicalId, + test: engines => + hasParams(engines, "Amazon.de", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "en-GB", + region: "GB", + distribution: canonicalId, + test: engines => + hasParams(engines, "Amazon.co.uk", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "fr", + region: "FR", + distribution: canonicalId, + test: engines => + hasParams(engines, "Amazon.fr", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "it", + region: "IT", + distribution: canonicalId, + test: engines => + hasParams(engines, "Amazon.it", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "ja", + region: "JP", + distribution: canonicalId, + test: engines => + hasParams(engines, "Amazon.co.jp", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "ur", + region: "IN", + distribution: canonicalId, + test: engines => + hasParams(engines, "Amazon.in", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "zh-CN", + region: "CN", + distribution: canonicalId, + test: engines => + hasParams(engines, "亚马逊", "searchbar", "tag=wwwcanoniccom-20"), + }); + + tests.push({ + locale: "zh-CN", + region: "CN", + distribution: canonicalId, + test: engines => + hasParams(engines, "百度", "searchbar", "tn=ubuntuu_cb") && + hasParams(engines, "百度", "suggestions", "tn=ubuntuu_cb"), + }); +} + +tests.push({ + locale: "ru", + distribution: "mailru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900201") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900201") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "ru", + region: "RU", + distribution: "mailru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900201") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900201") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "az", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900209") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900209") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "en-US", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900205") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900205") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "hy-AM", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900211") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900211") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "kk", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900206") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900206") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "kk", + region: "KZ", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900206") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900206") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "ro", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900207") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900207") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "ru", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900203") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900203") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "ru", + region: "RU", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900203") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900203") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "tr", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900210") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900210") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "tr", + region: "TR", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900210") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900210") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "uk", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900204") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900204") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "uz", + distribution: "okru-001", + test: engines => + hasParams(engines, "Поиск Mail.Ru", "searchbar", "gp=900208") && + hasParams(engines, "Поиск Mail.Ru", "searchbar", "frc=900208") && + hasDefault(engines, "Поиск Mail.Ru") && + hasEnginesFirst(engines, ["Поиск Mail.Ru"]), +}); + +tests.push({ + locale: "zh-CN", + distribution: "MozillaOnline", + test: engines => + hasParams(engines, "百度", "searchbar", "tn=monline_4_dg") && + hasParams(engines, "百度", "suggestions", "tn=monline_4_dg") && + hasParams(engines, "百度", "homepage", "tn=monline_3_dg") && + hasParams(engines, "百度", "newtab", "tn=monline_3_dg") && + hasParams(engines, "百度", "contextmenu", "tn=monline_4_dg") && + hasParams(engines, "百度", "keyword", "tn=monline_4_dg") && + hasDefault(engines, "百度") && + hasEnginesFirst(engines, ["百度", "Bing", "Google", "亚马逊", "维基百科"]), +}); + +tests.push({ + locale: "zh-CN", + distribution: "MozillaOnline", + test: engines => + hasParams(engines, "亚马逊", "searchbar", "engine=amazon_shopping") && + hasParams(engines, "亚马逊", "suggestions", "tag=mozilla") && + hasParams(engines, "亚马逊", "homepage", "create=2028") && + hasParams(engines, "亚马逊", "homepage", "adid=1NZNRHJZ2Q87NTS7YW6N") && + hasParams(engines, "亚马逊", "homepage", "campaign=408") && + hasParams(engines, "亚马逊", "homepage", "create=2028") && + hasParams(engines, "亚马逊", "homepage", "mode=blended") && + hasEnginesFirst(engines, ["百度", "Bing", "Google", "亚马逊", "维基百科"]), +}); + +tests.push({ + locale: "fr", + distribution: "qwant-001", + test: engines => + hasParams(engines, "Qwant", "searchbar", "client=firefoxqwant") && + hasDefault(engines, "Qwant") && + hasEnginesFirst(engines, ["Qwant", "Qwant Junior"]), +}); + +tests.push({ + locale: "fr", + distribution: "qwant-001", + test: engines => + hasParams(engines, "Qwant Junior", "searchbar", "client=firefoxqwant"), +}); + +tests.push({ + locale: "fr", + distribution: "qwant-002", + test: engines => + hasParams(engines, "Qwant", "searchbar", "client=firefoxqwant") && + hasDefault(engines, "Qwant") && + hasEnginesFirst(engines, ["Qwant", "Qwant Junior"]), +}); + +tests.push({ + locale: "fr", + distribution: "qwant-002", + test: engines => + hasParams(engines, "Qwant Junior", "searchbar", "client=firefoxqwant"), +}); + +tests.push({ + locale: "cs", + distribution: "seznam", + test: engines => + hasParams(engines, "Seznam", "searchbar", "sourceid=FF_3") && + hasDefault(engines, "Seznam") && + hasEnginesFirst(engines, ["Seznam"]), +}); + +for (const locale of ["en-US", "en-GB", "fr", "de"]) { + tests.push({ + locale, + distribution: "sweetlabs-b-oem1", + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MZSL01") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + locale, + distribution: "sweetlabs-b-r-oem1", + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MZSL01") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + locale, + distribution: "sweetlabs-b-oem2", + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MZSL02") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + locale, + distribution: "sweetlabs-b-r-oem2", + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MZSL02") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + locale, + distribution: "sweetlabs-b-oem3", + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MZSL03") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + locale, + distribution: "sweetlabs-b-r-oem3", + test: engines => + hasParams(engines, "Bing", "searchbar", "pc=MZSL03") && + hasDefault(engines, "Bing") && + hasEnginesFirst(engines, ["Bing"]), + }); + + tests.push({ + locale, + distribution: "sweetlabs-oem1", + test: engines => + hasParams(engines, "Google", "searchbar", "client=firefox-b-oem1") && + hasDefault(engines, "Google") && + hasEnginesFirst(engines, ["Google"]) && + hasTelemetryId(engines, "Google", "google-sweetlabs"), + }); + + tests.push({ + locale, + distribution: "sweetlabs-r-oem1", + test: engines => + hasParams(engines, "Google", "searchbar", "client=firefox-b-oem1") && + hasDefault(engines, "Google") && + hasEnginesFirst(engines, ["Google"]) && + hasTelemetryId(engines, "Google", "google-sweetlabs"), + }); + + tests.push({ + locale, + distribution: "sweetlabs-oem2", + test: engines => + hasParams(engines, "Google", "searchbar", "client=firefox-b-oem2") && + hasDefault(engines, "Google") && + hasEnginesFirst(engines, ["Google"]) && + hasTelemetryId(engines, "Google", "google-sweetlabs"), + }); + + tests.push({ + locale, + distribution: "sweetlabs-r-oem2", + test: engines => + hasParams(engines, "Google", "searchbar", "client=firefox-b-oem2") && + hasDefault(engines, "Google") && + hasEnginesFirst(engines, ["Google"]) && + hasTelemetryId(engines, "Google", "google-sweetlabs"), + }); +} + +for (const locale of ["en-US", "de"]) { + tests.push({ + locale, + distribution: "1und1", + test: engines => + hasParams(engines, "1&1 Suche", "searchbar", "enc=UTF-8") && + hasDefault(engines, "1&1 Suche") && + hasEnginesFirst(engines, ["1&1 Suche"]), + }); + + tests.push({ + locale, + distribution: "gmx", + test: engines => + hasParams(engines, "GMX Suche", "searchbar", "enc=UTF-8") && + hasDefault(engines, "GMX Suche") && + hasEnginesFirst(engines, ["GMX Suche"]), + }); + + tests.push({ + locale, + distribution: "gmx", + test: engines => + hasParams(engines, "GMX Shopping", "searchbar", "origin=br_osd"), + }); + + tests.push({ + locale, + distribution: "mail.com", + test: engines => + hasParams(engines, "mail.com search", "searchbar", "enc=UTF-8") && + hasDefault(engines, "mail.com search") && + hasEnginesFirst(engines, ["mail.com search"]), + }); + + tests.push({ + locale, + distribution: "webde", + test: engines => + hasParams(engines, "WEB.DE Suche", "searchbar", "enc=UTF-8") && + hasDefault(engines, "WEB.DE Suche") && + hasEnginesFirst(engines, ["WEB.DE Suche"]), + }); +} + +tests.push({ + locale: "ru", + region: "RU", + distribution: "gmx", + test: engines => hasDefault(engines, "GMX Suche"), +}); + +tests.push({ + locale: "en-GB", + distribution: "gmxcouk", + test: engines => + hasURLs( + engines, + "GMX Search", + "https://go.gmx.co.uk/br/moz_search_web/?enc=UTF-8&q=test", + "https://suggestplugin.gmx.co.uk/s?q=test&brand=gmxcouk&origin=moz_splugin_ff&enc=UTF-8" + ) && + hasDefault(engines, "GMX Search") && + hasEnginesFirst(engines, ["GMX Search"]), +}); + +tests.push({ + locale: "ru", + region: "RU", + distribution: "gmxcouk", + test: engines => hasDefault(engines, "GMX Search"), +}); + +tests.push({ + locale: "es", + distribution: "gmxes", + test: engines => + hasURLs( + engines, + "GMX - Búsqueda web", + "https://go.gmx.es/br/moz_search_web/?enc=UTF-8&q=test", + "https://suggestplugin.gmx.es/s?q=test&brand=gmxes&origin=moz_splugin_ff&enc=UTF-8" + ) && + hasDefault(engines, "GMX Search") && + hasEnginesFirst(engines, ["GMX Search"]), +}); + +tests.push({ + locale: "ru", + region: "RU", + distribution: "gmxes", + test: engines => hasDefault(engines, "GMX - Búsqueda web"), +}); + +tests.push({ + locale: "fr", + distribution: "gmxfr", + test: engines => + hasURLs( + engines, + "GMX - Recherche web", + "https://go.gmx.fr/br/moz_search_web/?enc=UTF-8&q=test", + "https://suggestplugin.gmx.fr/s?q=test&brand=gmxfr&origin=moz_splugin_ff&enc=UTF-8" + ) && + hasDefault(engines, "GMX Search") && + hasEnginesFirst(engines, ["GMX Search"]), +}); + +tests.push({ + locale: "ru", + region: "RU", + distribution: "gmxfr", + test: engines => hasDefault(engines, "GMX - Recherche web"), +}); + +tests.push({ + locale: "ru", + distribution: "yandex-drp", + test: engines => + hasParams(engines, "Яндекс", "searchbar", "clid=2039342") && + // Test that fallback works correct as well. + hasParams(engines, "Яндекс", "contextmenu", "clid=2039342") && + hasDefault(engines, "Яндекс") && + hasEnginesFirst(engines, ["Яндекс"]), +}); + +tests.push({ + locale: "ru", + distribution: "yandex-planb", + test: engines => + hasParams(engines, "Яндекс", "searchbar", "clid=1857376") && + hasDefault(engines, "Яндекс") && + hasEnginesFirst(engines, ["Яндекс"]), +}); + +tests.push({ + locale: "ru", + distribution: "yandex-portals", + test: engines => + hasParams(engines, "Яндекс", "searchbar", "clid=1923034") && + hasDefault(engines, "Яндекс") && + hasEnginesFirst(engines, ["Яндекс"]), +}); + +tests.push({ + locale: "ru", + distribution: "yandex-ru", + test: engines => + hasParams(engines, "Яндекс", "searchbar", "clid=1923018") && + hasDefault(engines, "Яндекс") && + hasEnginesFirst(engines, ["Яндекс"]), +}); + +tests.push({ + locale: "tr", + distribution: "yandex-tr", + test: engines => + hasParams(engines, "Yandex", "searchbar", "clid=1953197") && + hasDefault(engines, "Yandex") && + hasEnginesFirst(engines, ["Yandex"]), +}); + +tests.push({ + locale: "tr", + distribution: "yandex-tr-gezginler", + test: engines => + hasParams(engines, "Yandex", "searchbar", "clid=1945716") && + hasDefault(engines, "Yandex") && + hasEnginesFirst(engines, ["Yandex"]), +}); + +tests.push({ + locale: "tr", + distribution: "yandex-tr-tamindir", + test: engines => + hasParams(engines, "Yandex", "searchbar", "clid=1945686") && + hasDefault(engines, "Yandex") && + hasEnginesFirst(engines, ["Yandex"]), +}); + +tests.push({ + locale: "uk", + distribution: "yandex-uk", + test: engines => + hasParams(engines, "Яндекс", "searchbar", "clid=1923018") && + hasDefault(engines, "Яндекс") && + hasEnginesFirst(engines, ["Яндекс"]), +}); + +tests.push({ + locale: "ru", + distribution: "yandex-ru-mz", + test: engines => + hasParams(engines, "Яндекс", "searchbar", "clid=2320519") && + hasDefault(engines, "Яндекс") && + hasEnginesFirst(engines, ["Яндекс"]), +}); + +function hasURLs(engines, engineName, url, suggestURL) { + let engine = engines.find(e => e._name === engineName); + Assert.ok(engine, `Should be able to find ${engineName}`); + + let submission = engine.getSubmission("test", "text/html"); + Assert.equal( + submission.uri.spec, + url, + `Should have the correct submission url for ${engineName}` + ); + + submission = engine.getSubmission("test", "application/x-suggestions+json"); + Assert.equal( + submission.uri.spec, + suggestURL, + `Should have the correct suggestion url for ${engineName}` + ); +} + +function hasParams(engines, engineName, purpose, param) { + let engine = engines.find(e => e._name === engineName); + Assert.ok(engine, `Should be able to find ${engineName}`); + + let submission = engine.getSubmission("test", "text/html", purpose); + let queries = submission.uri.query.split("&"); + + let paramNames = new Set(); + for (let query of queries) { + let queryParam = query.split("=")[0]; + Assert.ok( + !paramNames.has(queryParam), + `Should not have a duplicate ${queryParam} param` + ); + paramNames.add(queryParam); + } + + let result = queries.includes(param); + Assert.ok(result, `expect ${submission.uri.query} to include ${param}`); + return true; +} + +function hasTelemetryId(engines, engineName, telemetryId) { + let engine = engines.find(e => e._name === engineName); + Assert.ok(engine, `Should be able to find ${engineName}`); + + Assert.equal( + engine.telemetryId, + telemetryId, + "Should have the correct telemetryId" + ); + return true; +} + +function hasDefault(engines, expectedDefaultName) { + Assert.equal( + engines[0].name, + expectedDefaultName, + "Should have the expected engine set as default" + ); + return true; +} + +function hasEnginesFirst(engines, expectedEngines) { + for (let [i, expectedEngine] of expectedEngines.entries()) { + Assert.equal( + engines[i].name, + expectedEngine, + `Should have the expected engine in position ${i}` + ); + } +} + +engineSelector = new SearchEngineSelector(); + +AddonTestUtils.init(GLOBAL_SCOPE); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + await maybeSetupConfig(); +}); + +add_task(async function test_expected_distribution_engines() { + let searchService = new SearchService(); + for (const { distribution, locale = "en-US", region = "US", test } of tests) { + let config = await engineSelector.fetchEngineConfiguration({ + locale, + region, + distroID: distribution, + }); + let engines = await SearchTestUtils.searchConfigToEngines(config.engines); + searchService._engines = engines; + searchService._searchDefault = { + id: config.engines[0].webExtension.id, + locale: + config.engines[0]?.webExtension?.locale ?? SearchUtils.DEFAULT_TAG, + }; + engines = searchService._sortEnginesByDefaults(engines); + test(engines); + } +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_duckduckgo.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_duckduckgo.js new file mode 100644 index 0000000000..fb20d1b452 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_duckduckgo.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const test = new SearchConfigTest({ + identifier: "ddg", + aliases: ["@duckduckgo", "@ddg"], + default: { + // Not included anywhere. + }, + available: { + excluded: [ + // Should be available everywhere. + ], + }, + details: [ + { + included: [{}], + domain: "duckduckgo.com", + telemetryId: "ddg", + codes: { + searchbar: "t=ffsb", + keyword: "t=ffab", + contextmenu: "t=ffcm", + homepage: "t=ffhp", + newtab: "t=ffnt", + }, + }, + ], +}); + +add_task(async function setup() { + await test.setup(); +}); + +add_task(async function test_searchConfig_duckduckgo() { + await test.run(); +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_ebay.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_ebay.js new file mode 100644 index 0000000000..2fbfd77d16 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_ebay.js @@ -0,0 +1,287 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const availableRegions = [ + ...Services.intl.getAvailableLocaleDisplayNames("region"), + null, +]; + +const DOMAIN_LOCALES = { + "ebay-ca": ["en-CA"], + "ebay-ch": ["rm"], + "ebay-de": ["de", "dsb", "hsb"], + "ebay-es": ["an", "ast", "ca", "ca-valencia", "es-ES", "eu", "gl"], + "ebay-ie": ["ga-IE", "ie"], + "ebay-it": ["it", "lij"], + "ebay-nl": ["fy-NL", "nl"], + "ebay-uk": ["cy", "en-GB", "gd"], +}; + +const test = new SearchConfigTest({ + identifier: "ebay", + aliases: ["@ebay"], + default: { + // Not included anywhere. + }, + available: { + included: [ + { + // We don't currently enforce by region, but do locale instead. + // regions: [ + // "us", "gb", "ca", "ie", "fr", "it", "de", "at", "es", "nl", "ch", "au" + // ], + locales: { + matches: [ + "an", + "ast", + "br", + "ca", + "ca-valencia", + "cy", + "de", + "dsb", + "en-CA", + "en-GB", + "es-ES", + "eu", + "fr", + "fy-NL", + "ga-IE", + "gd", + "gl", + "hsb", + "it", + "lij", + "nl", + "rm", + "wo", + ], + }, + }, + { + regions: ["au", "be", "ca", "ch", "gb", "ie", "nl", "us"], + locales: { + matches: ["en-US"], + }, + }, + ], + }, + suggestionUrlBase: "https://autosug.ebay.com/autosug", + details: [ + { + // Note: These should be based on region, but we don't currently enforce that. + // Note: the order here is important. A region/locale match higher up in the + // list will override a region/locale match lower down. + domain: "befr.ebay.be", + telemetryId: "ebay-be", + included: [ + { + regions: ["be"], + locales: { + matches: ["br", "unknown", "en-US", "fr", "fy-NL", "nl", "wo"], + }, + }, + ], + searchUrlCode: "mkrid=1553-53471-19255-0", + suggestUrlCode: "sId=23", + }, + { + domain: "ebay.at", + telemetryId: "ebay-at", + included: [ + { + regions: ["at"], + locales: { matches: ["de", "dsb", "hsb"] }, + }, + ], + searchUrlCode: "mkrid=5221-53469-19255-0", + suggestUrlCode: "sId=16", + }, + { + domain: "ebay.ca", + telemetryId: "ebay-ca", + included: [ + { + locales: { matches: DOMAIN_LOCALES["ebay-ca"] }, + }, + { + regions: ["ca"], + }, + ], + excluded: [ + { + locales: { + matches: [ + ...DOMAIN_LOCALES["ebay-ch"], + ...DOMAIN_LOCALES["ebay-de"], + ...DOMAIN_LOCALES["ebay-es"], + ...DOMAIN_LOCALES["ebay-ie"], + ...DOMAIN_LOCALES["ebay-it"], + ...DOMAIN_LOCALES["ebay-nl"], + ...DOMAIN_LOCALES["ebay-uk"], + ], + }, + }, + ], + searchUrlCode: "mkrid=706-53473-19255-0", + suggestUrlCode: "sId=2", + }, + { + domain: "ebay.ch", + telemetryId: "ebay-ch", + included: [ + { + locales: { matches: DOMAIN_LOCALES["ebay-ch"] }, + }, + { + regions: ["ch"], + }, + ], + excluded: [ + { + locales: { + matches: [ + ...DOMAIN_LOCALES["ebay-ca"], + ...DOMAIN_LOCALES["ebay-es"], + ...DOMAIN_LOCALES["ebay-ie"], + ...DOMAIN_LOCALES["ebay-it"], + ...DOMAIN_LOCALES["ebay-nl"], + ...DOMAIN_LOCALES["ebay-uk"], + ], + }, + }, + ], + searchUrlCode: "mkrid=5222-53480-19255-0", + suggestUrlCode: "sId=193", + }, + { + domain: "ebay.com", + telemetryId: "ebay", + included: [ + { + locales: { matches: ["unknown", "en-US"] }, + }, + ], + excluded: [{ regions: ["au", "be", "ca", "ch", "gb", "ie", "nl"] }], + searchUrlCode: "mkrid=711-53200-19255-0", + suggestUrlCode: "sId=0", + }, + { + domain: "ebay.com.au", + telemetryId: "ebay-au", + included: [ + { + regions: ["au"], + locales: { matches: ["cy", "unknown", "en-GB", "en-US", "gd"] }, + }, + ], + searchUrlCode: "mkrid=705-53470-19255-0", + suggestUrlCode: "sId=15", + }, + { + domain: "ebay.ie", + telemetryId: "ebay-ie", + included: [ + { + locales: { matches: DOMAIN_LOCALES["ebay-ie"] }, + }, + { + regions: ["ie"], + locales: { matches: ["cy", "unknown", "en-GB", "en-US", "gd"] }, + }, + ], + searchUrlCode: "mkrid=5282-53468-19255-0", + suggestUrlCode: "sId=205", + }, + { + domain: "ebay.co.uk", + telemetryId: "ebay-uk", + included: [ + { + locales: { matches: DOMAIN_LOCALES["ebay-uk"] }, + }, + { + locales: { matches: ["unknown", "en-US"] }, + regions: ["gb"], + }, + ], + excluded: [{ regions: ["au", "ie"] }], + searchUrlCode: "mkrid=710-53481-19255-0", + suggestUrlCode: "sId=3", + }, + { + domain: "ebay.de", + telemetryId: "ebay-de", + included: [ + { + locales: { matches: DOMAIN_LOCALES["ebay-de"] }, + }, + ], + excluded: [{ regions: ["at", "ch"] }], + searchUrlCode: "mkrid=707-53477-19255-0", + suggestUrlCode: "sId=77", + }, + { + domain: "ebay.es", + telemetryId: "ebay-es", + included: [ + { + locales: { + matches: DOMAIN_LOCALES["ebay-es"], + }, + }, + ], + searchUrlCode: "mkrid=1185-53479-19255-0", + suggestUrlCode: "sId=186", + }, + { + domain: "ebay.fr", + telemetryId: "ebay-fr", + included: [ + { + locales: { matches: ["br", "fr", "wo"] }, + }, + ], + excluded: [{ regions: ["be", "ca", "ch"] }], + searchUrlCode: "mkrid=709-53476-19255-0", + suggestUrlCode: "sId=71", + }, + { + domain: "ebay.it", + telemetryId: "ebay-it", + included: [ + { + locales: { matches: DOMAIN_LOCALES["ebay-it"] }, + }, + ], + searchUrlCode: "mkrid=724-53478-19255-0", + suggestUrlCode: "sId=101", + }, + { + domain: "ebay.nl", + telemetryId: "ebay-nl", + included: [ + { + locales: { matches: DOMAIN_LOCALES["ebay-nl"] }, + }, + { + locales: { matches: ["unknown", "en-US"] }, + regions: ["nl"], + }, + ], + excluded: [{ regions: ["be"] }], + searchUrlCode: "mkrid=1346-53482-19255-0", + suggestUrlCode: "sId=146", + }, + ], +}); + +add_task(async function setup() { + await test.setup(); +}); + +add_task(async function test_searchConfig_ebay() { + await test.run(); +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_google.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_google.js new file mode 100644 index 0000000000..2a7068c29f --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_google.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const test = new SearchConfigTest({ + identifier: "google", + aliases: ["@google"], + default: { + // Included everywhere apart from the exclusions below. These are basically + // just excluding what Yandex and Baidu include. + excluded: [ + { + regions: ["ru", "tr", "by", "kz"], + locales: { + matches: ["ru", "tr", "be", "kk"], + startsWith: ["en"], + }, + }, + { + regions: ["cn"], + locales: { + matches: ["zh-CN"], + }, + }, + ], + }, + available: { + excluded: [ + // Should be available everywhere. + ], + }, + details: [ + { + included: [{ regions: ["us"] }], + domain: "google.com", + telemetryId: AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "google-b-1-e" + : "google-b-1-d", + codes: AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "client=firefox-b-1-e" + : "client=firefox-b-1-d", + }, + { + excluded: [{ regions: ["us", "by", "kz", "ru", "tr"] }], + included: [{}], + domain: "google.com", + telemetryId: AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "google-b-e" + : "google-b-d", + codes: AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "client=firefox-b-e" + : "client=firefox-b-d", + }, + { + excluded: [{ regions: ["us"] }], + included: [{ regions: ["by", "kz", "ru", "tr"] }], + domain: "google.com", + codes: AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") + ? "client=firefox-b-e" + : "client=firefox-b-d", + }, + ], +}); + +add_task(async function setup() { + await test.setup(); +}); + +add_task(async function test_searchConfig_google() { + await test.run(); +}); + +add_task(async function test_searchConfig_google_with_mozparam() { + // Test a couple of configurations with a MozParam set up. + const TEST_DATA = [ + { + locale: "en-US", + region: "US", + pref: "google_channel_us", + expected: "us_param", + }, + { + locale: "en-US", + region: "GB", + pref: "google_channel_row", + expected: "row_param", + }, + ]; + + const defaultBranch = Services.prefs.getDefaultBranch( + SearchUtils.BROWSER_SEARCH_PREF + ); + for (const testData of TEST_DATA) { + defaultBranch.setCharPref("param." + testData.pref, testData.expected); + } + + for (const testData of TEST_DATA) { + info(`Checking region ${testData.region}, locale ${testData.locale}`); + const engines = await test._getEngines(testData.region, testData.locale); + + Assert.ok( + engines[0].identifier.startsWith("google"), + "Should have the correct engine" + ); + console.log(engines[0]); + + const submission = engines[0].getSubmission("test", URLTYPE_SEARCH_HTML); + Assert.ok( + submission.uri.query.split("&").includes("channel=" + testData.expected), + "Should be including the correct MozParam parameter for the engine" + ); + } +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_yandex.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_yandex.js new file mode 100644 index 0000000000..5495c672ac --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_yandex.js @@ -0,0 +1,117 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const test = new SearchConfigTest({ + identifier: "yandex", + aliases: ["@\u044F\u043D\u0434\u0435\u043A\u0441", "@yandex"], + default: { + included: [ + { + regions: ["ru", "tr", "by", "kz"], + locales: { + matches: ["ru", "tr", "be", "kk"], + startsWith: ["en"], + }, + }, + ], + }, + available: { + included: [ + { + locales: { + matches: ["az", "ru", "be", "kk", "tr"], + }, + }, + { + regions: ["ru", "tr", "by", "kz"], + locales: { + startsWith: ["en"], + }, + }, + ], + }, + details: [ + { + included: [{ locales: { matches: ["az"] } }], + domain: "yandex.az", + telemetryId: "yandex-az", + codes: { + searchbar: "clid=2186618", + keyword: "clid=2186621", + contextmenu: "clid=2186623", + homepage: "clid=2186617", + newtab: "clid=2186620", + }, + }, + { + included: [{ locales: { startsWith: ["en"] } }], + domain: "yandex.com", + telemetryId: "yandex-en", + codes: { + searchbar: "clid=2186618", + keyword: "clid=2186621", + contextmenu: "clid=2186623", + homepage: "clid=2186617", + newtab: "clid=2186620", + }, + }, + { + included: [{ locales: { matches: ["ru"] } }], + domain: "yandex.ru", + telemetryId: "yandex-ru", + codes: { + searchbar: "clid=2186618", + keyword: "clid=2186621", + contextmenu: "clid=2186623", + homepage: "clid=2186617", + newtab: "clid=2186620", + }, + }, + { + included: [{ locales: { matches: ["be"] } }], + domain: "yandex.by", + telemetryId: "yandex-by", + codes: { + searchbar: "clid=2186618", + keyword: "clid=2186621", + contextmenu: "clid=2186623", + homepage: "clid=2186617", + newtab: "clid=2186620", + }, + }, + { + included: [{ locales: { matches: ["kk"] } }], + domain: "yandex.kz", + telemetryId: "yandex-kk", + codes: { + searchbar: "clid=2186618", + keyword: "clid=2186621", + contextmenu: "clid=2186623", + homepage: "clid=2186617", + newtab: "clid=2186620", + }, + }, + { + included: [{ locales: { matches: ["tr"] } }], + domain: "yandex.com.tr", + telemetryId: "yandex-tr", + codes: { + searchbar: "clid=2186618", + keyword: "clid=2186621", + contextmenu: "clid=2186623", + homepage: "clid=2186617", + newtab: "clid=2186620", + }, + }, + ], +}); + +add_task(async function setup() { + await test.setup(); +}); + +add_task(async function test_searchConfig_yandex() { + await test.run(); +}); diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/xpcshell.ini b/toolkit/components/search/tests/xpcshell/searchconfigs/xpcshell.ini new file mode 100644 index 0000000000..cf9768d248 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/searchconfigs/xpcshell.ini @@ -0,0 +1,22 @@ +[DEFAULT] +firefox-appdir = browser +head = head_searchconfig.js +dupe-manifest = +support-files = + ../../../../../../browser/locales/all-locales +tags=searchconfig +# These are extensive tests, we don't need to run them on asan/tsan. +# They are also skipped for mobile and Thunderbird as these are specifically +# testing the Firefox config at the moment. +skip-if = toolkit == 'android' || appname == "thunderbird" || asan || tsan || debug || (os == "win" && ccov) +# These tests do take a little longer on Linux ccov, so allow that here. +requesttimeoutfactor = 2 + +[test_amazon.js] +[test_baidu.js] +[test_bing.js] +[test_distributions.js] +[test_duckduckgo.js] +[test_ebay.js] +[test_google.js] +[test_yandex.js] diff --git a/toolkit/components/search/tests/xpcshell/simple-engines/basic/manifest.json b/toolkit/components/search/tests/xpcshell/simple-engines/basic/manifest.json new file mode 100644 index 0000000000..a38a8bc581 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/simple-engines/basic/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "basic", + "manifest_version": 2, + "version": "1.0", + "description": "basic", + "applications": { + "gecko": { + "id": "basic@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "basic", + "search_url": "https://ar.wikipedia.org/wiki/%D8%AE%D8%A7%D8%B5:%D8%A8%D8%AD%D8%AB", + "params": [ + { + "name": "search", + "value": "{searchTerms}" + }, + { + "name": "sourceId", + "value": "Mozilla-search" + } + ], + "suggest_url": "https://ar.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/simple-engines/engines.json b/toolkit/components/search/tests/xpcshell/simple-engines/engines.json new file mode 100644 index 0000000000..9101955cb0 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/simple-engines/engines.json @@ -0,0 +1,23 @@ +{ + "data": [ + { + "webExtension": { + "id":"basic@search.mozilla.org" + }, + "telemetryId": "telemetry", + "appliesTo": [{ + "included": { "everywhere": true }, + "default": "yes" + }] + }, + { + "webExtension": { + "id":"simple@search.mozilla.org" + }, + "appliesTo": [{ + "included": { "everywhere": true }, + "default": "yes" + }] + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/simple-engines/hidden/manifest.json b/toolkit/components/search/tests/xpcshell/simple-engines/hidden/manifest.json new file mode 100644 index 0000000000..9e947cd527 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/simple-engines/hidden/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "hidden", + "manifest_version": 2, + "version": "1.0", + "description": "Hidden engine to test bug 1194265", + "applications": { + "gecko": { + "id": "hidden@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "hidden", + "search_url": "https://ar.wikipedia.org/wiki/%D8%AE%D8%A7%D8%B5:%D8%A8%D8%AD%D8%AB", + "params": [ + { + "name": "search", + "value": "{searchTerms}" + }, + { + "name": "sourceId", + "value": "Mozilla-search" + } + ], + "suggest_url": "https://ar.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/simple-engines/simple/manifest.json b/toolkit/components/search/tests/xpcshell/simple-engines/simple/manifest.json new file mode 100644 index 0000000000..36f0c72638 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/simple-engines/simple/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "Simple Engine", + "manifest_version": 2, + "version": "1.0", + "description": "Simple engine with a different name from the WebExtension id prefix", + "applications": { + "gecko": { + "id": "simple@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "Simple Engine", + "search_url": "https://example.com", + "params": [ + { + "name": "sourceId", + "value": "Mozilla-search" + }, + { + "name": "search", + "value": "{searchTerms}" + } + ], + "suggest_url": "https://example.com?search={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/engines.json b/toolkit/components/search/tests/xpcshell/test-extensions/engines.json new file mode 100644 index 0000000000..87a8cbf5fa --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/engines.json @@ -0,0 +1,49 @@ +{ + "data": [ + { + "webExtension": { + "id": "plainengine@search.mozilla.org" + }, + "orderHint": 10000, + "sendAttributionRequest": true, + "appliesTo": [{ + "included": { "everywhere": true }, + "default": "yes-if-no-other" + }] + }, + { + "webExtension": { + "id": "special-engine@search.mozilla.org" + }, + "orderHint": 7000, + "appliesTo": [{ + "included": { "regions": ["tr"] }, + "default": "yes" + }, { + "included": { "everywhere": true }, + "sendAttributionRequest": true + }] + }, + { + "webExtension": { + "id": "multilocale@search.mozilla.org", + "locales": ["an"] + }, + "orderHint": 6000, + "appliesTo": [{ + "included": { "regions": ["an"] }, + "default": "yes" + }] + }, + { + "webExtension": { + "id": "multilocale@search.mozilla.org", + "locales": ["af", "an"] + }, + "orderHint": 6500, + "appliesTo": [{ + "included": { "regions": ["af"] } + }] + } + ] +} diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/af/messages.json b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/af/messages.json new file mode 100644 index 0000000000..95e49f9bc5 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/af/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Multilocale AF" + }, + "extensionDescription": { + "message": "Wikipedia, die vrye ensiklopedie" + }, + "url_lang": { + "message": "af" + }, + "searchUrl": { + "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek" + }, + "suggestUrl": { + "message": "https://af.wikipedia.org/w/api.php" + }, + "extensionIcon": { + "message": "favicon-af.ico" + } +} diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/an/messages.json b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/an/messages.json new file mode 100644 index 0000000000..6222338596 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/an/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Multilocale AN" + }, + "extensionDescription": { + "message": "A enciclopedia Libre" + }, + "url_lang": { + "message": "an" + }, + "searchUrl": { + "message": "https://an.wikipedia.org/wiki/Especial:Mirar" + }, + "suggestUrl": { + "message": "https://an.wikipedia.org/w/api.php" + }, + "extensionIcon": { + "message": "favicon-an.ico" + } +} diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-af.ico b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-af.ico Binary files differnew file mode 100644 index 0000000000..4314071e24 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-af.ico diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-an.ico b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-an.ico Binary files differnew file mode 100644 index 0000000000..dda80dfd88 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-an.ico diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/manifest.json b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/manifest.json new file mode 100644 index 0000000000..f19e56c95c --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "__MSG_extensionName__", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "multilocale@search.mozilla.org" + } + }, + "hidden": true, + "description": "__MSG_extensionDescription__", + "icons": { + "16": "__MSG_extensionIcon__" + }, + "default_locale": "af", + "chrome_settings_overrides": { + "search_provider": { + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "suggest_url": "__MSG_searchUrl__" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/plainengine/favicon.ico b/toolkit/components/search/tests/xpcshell/test-extensions/plainengine/favicon.ico Binary files differnew file mode 100644 index 0000000000..dda80dfd88 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/plainengine/favicon.ico diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/plainengine/manifest.json b/toolkit/components/search/tests/xpcshell/test-extensions/plainengine/manifest.json new file mode 100644 index 0000000000..6351a9f649 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/plainengine/manifest.json @@ -0,0 +1,58 @@ +{ + "name": "Plain", + "description": "Plain Engine", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "plainengine@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "Plain", + "search_url": "https://duckduckgo.com/", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "t", + "condition": "purpose", + "purpose": "contextmenu", + "value": "ffcm" + }, + { + "name": "t", + "condition": "purpose", + "purpose": "keyword", + "value": "ffab" + }, + { + "name": "t", + "condition": "purpose", + "purpose": "searchbar", + "value": "ffsb" + }, + { + "name": "t", + "condition": "purpose", + "purpose": "homepage", + "value": "ffhp" + }, + { + "name": "t", + "condition": "purpose", + "purpose": "newtab", + "value": "ffnt" + } + ], + "suggest_url": "https://ac.duckduckgo.com/ac/q={searchTerms}&type=list" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/special-engine/favicon.ico b/toolkit/components/search/tests/xpcshell/test-extensions/special-engine/favicon.ico Binary files differnew file mode 100644 index 0000000000..82339b3b1d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/special-engine/favicon.ico diff --git a/toolkit/components/search/tests/xpcshell/test-extensions/special-engine/manifest.json b/toolkit/components/search/tests/xpcshell/test-extensions/special-engine/manifest.json new file mode 100644 index 0000000000..b530e051d0 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test-extensions/special-engine/manifest.json @@ -0,0 +1,40 @@ +{ + "name": "Special", + "description": "Special Engine", + "manifest_version": 2, + "version": "1.0", + "applications": { + "gecko": { + "id": "special-engine@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "chrome_settings_overrides": { + "search_provider": { + "name": "Special", + "search_url": "https://www.google.com/search", + "params": [ + { + "name": "q", + "value": "{searchTerms}" + }, + { + "name": "client", + "condition": "purpose", + "purpose": "keyword", + "value": "firefox-b-1-ab" + }, + { + "name": "client", + "condition": "purpose", + "purpose": "searchbar", + "value": "firefox-b-1" + } + ], + "suggest_url": "https://www.google.com/complete/search?client=firefox&q={searchTerms}" + } + } +} diff --git a/toolkit/components/search/tests/xpcshell/test_SearchStaticData.js b/toolkit/components/search/tests/xpcshell/test_SearchStaticData.js new file mode 100644 index 0000000000..b99ea4d3e6 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_SearchStaticData.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests the SearchStaticData module. + */ + +"use strict"; + +ChromeUtils.import("resource://gre/modules/SearchStaticData.jsm", this); + +function run_test() { + Assert.ok( + SearchStaticData.getAlternateDomains("www.google.com").includes( + "www.google.fr" + ) + ); + Assert.ok( + SearchStaticData.getAlternateDomains("www.google.fr").includes( + "www.google.com" + ) + ); + Assert.ok( + SearchStaticData.getAlternateDomains("www.google.com").every(d => + d.startsWith("www.google.") + ) + ); + Assert.ok(!SearchStaticData.getAlternateDomains("google.com").length); + + // Test that methods from SearchStaticData module can be overwritten, + // needed for hotfixing. + let backup = SearchStaticData.getAlternateDomains; + SearchStaticData.getAlternateDomains = () => ["www.bing.fr"]; + Assert.deepEqual(SearchStaticData.getAlternateDomains("www.bing.com"), [ + "www.bing.fr", + ]); + SearchStaticData.getAlternateDomains = backup; +} diff --git a/toolkit/components/search/tests/xpcshell/test_addEngineWithDetails.js b/toolkit/components/search/tests/xpcshell/test_addEngineWithDetails.js new file mode 100644 index 0000000000..71f97d34bd --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_addEngineWithDetails.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kSearchEngineID = "addEngineWithDetails_test_engine"; +const kSearchEngineURL = "http://example.com/?search={searchTerms}"; +const kSearchTerm = "foo"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_addEngineWithDetails() { + Assert.ok(!Services.search.isInitialized); + + await Services.search.addEngineWithDetails(kSearchEngineID, { + method: "get", + template: kSearchEngineURL, + }); + + // An engine added with addEngineWithDetails should have a load path, even + // though we can't point to a specific file. + let engine = Services.search.getEngineByName(kSearchEngineID); + Assert.equal( + engine.wrappedJSObject._loadPath, + `[other]addEngineWithDetails:${kSearchEngineID}@test.engine` + ); + Assert.ok( + !engine.isAppProvided, + "Should not be shown as an app-provided engine" + ); + Assert.equal(engine.searchUrlPublicSuffix, "com"); + + // Set the engine as default; this should set a loadPath verification hash, + // which should ensure we don't show the search reset prompt. + await Services.search.setDefault(engine); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = (await Services.search.getDefault()).getSubmission( + kSearchTerm, + null, + "searchbar" + ); + Assert.equal(submission.uri.spec, expectedURL); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_addEngineWithDetailsObject.js b/toolkit/components/search/tests/xpcshell/test_addEngineWithDetailsObject.js new file mode 100644 index 0000000000..d16c0231c8 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_addEngineWithDetailsObject.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kSearchEngineID = "addEngineWithDetails_test_engine"; +const kSearchEngineURL = "http://example.com/?search={searchTerms}"; +const kSearchSuggestURL = "http://example.com/?suggest={searchTerms}"; +const kIconURL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="; +const kDescription = "Test Description"; +const kAlias = "alias_foo"; +const kSearchTerm = "foo"; +const kExtensionID = "test@example.com"; +const URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; +const kSearchEnginePOSTID = "addEngineWithDetails_post_test_engine"; +const kSearchEnginePOSTURL = "http://example.com/"; +const kSearchEnginePOSTData = "search={searchTerms}&extra=more"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_addEngineWithDetails() { + Assert.ok(!Services.search.isInitialized); + + await Services.search.addEngineWithDetails(kSearchEngineID, { + template: kSearchEngineURL, + description: kDescription, + iconURL: kIconURL, + suggestURL: kSearchSuggestURL, + alias: "alias_foo", + extensionID: kExtensionID, + }); + + // An engine added with addEngineWithDetails should have a load path, even + // though we can't point to a specific file. + let engine = Services.search.getEngineByName(kSearchEngineID); + Assert.equal( + engine.wrappedJSObject._loadPath, + "[other]addEngineWithDetails:" + kExtensionID + ); + Assert.equal(engine.description, kDescription); + Assert.equal(engine.iconURI.spec, kIconURL); + Assert.ok(engine.aliases.includes(kAlias)); + + // Set the engine as default; this should set a loadPath verification hash, + // which should ensure we don't show the search reset prompt. + await Services.search.setDefault(engine); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = (await Services.search.getDefault()).getSubmission( + kSearchTerm, + null, + "searchbar" + ); + Assert.equal(submission.uri.spec, expectedURL); + let expectedSuggestURL = kSearchSuggestURL.replace( + "{searchTerms}", + kSearchTerm + ); + let submissionSuggest = (await Services.search.getDefault()).getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + Assert.equal(submissionSuggest.uri.spec, expectedSuggestURL); +}); + +add_task(async function test_addEngineWithDetailsPOST() { + Assert.ok(Services.search.isInitialized); + + await Services.search.addEngineWithDetails(kSearchEnginePOSTID, { + template: kSearchEnginePOSTURL, + method: "POST", + postData: kSearchEnginePOSTData, + }); + + let engine = Services.search.getEngineByName(kSearchEnginePOSTID); + + let expectedPOSTData = kSearchEnginePOSTData.replace( + "{searchTerms}", + kSearchTerm + ); + let submission = engine.getSubmission(kSearchTerm, null, "searchbar"); + Assert.equal(submission.uri.spec, kSearchEnginePOSTURL); + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(submission.postData); + let data = sis.read(submission.postData.available()); + Assert.equal(data, expectedPOSTData); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_addEngineWithExtensionID.js b/toolkit/components/search/tests/xpcshell/test_addEngineWithExtensionID.js new file mode 100644 index 0000000000..08d6b68223 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_addEngineWithExtensionID.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kSearchEngineID = "addEngineWithDetails_test_engine"; +const kSearchEngineURL = "https://example.com/?search={searchTerms}"; +const kSearchTerm = "foo"; +const kExtensionID1 = "extension1@mozilla.com"; + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_addEngineWithDetailsWithExtensionID() { + Assert.ok(!Services.search.isInitialized); + + await Services.search.addEngineWithDetails(kSearchEngineID, { + method: "get", + template: kSearchEngineURL, + extensionID: kExtensionID1, + }); + + let engine = Services.search.getEngineByName(kSearchEngineID); + Assert.notEqual(engine, null); + Assert.ok( + !engine.isAppProvided, + "Should not be shown as an app-provided engine" + ); + + let engines = await Services.search.getEnginesByExtensionID(kExtensionID1); + Assert.equal(engines.length, 1); + Assert.equal(engines[0].name, engine.name); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_async.js b/toolkit/components/search/tests/xpcshell/test_async.js new file mode 100644 index 0000000000..e6fcff9eea --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_async.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await SearchTestUtils.useTestEngines("simple-engines"); +}); + +add_task(async function test_async() { + Assert.ok(!Services.search.isInitialized); + + let aStatus = await Services.search.init(); + Assert.ok(Components.isSuccessCode(aStatus)); + Assert.ok(Services.search.isInitialized); + + // test engines from dir are not loaded. + let engines = await Services.search.getEngines(); + Assert.equal(engines.length, 2); + + // test jar engine is loaded ok. + let engine = Services.search.getEngineByName("basic"); + Assert.notEqual(engine, null); + Assert.ok(engine.isAppProvided, "Should be shown as an app-provided engine"); + + engine = Services.search.getEngineByName("Simple Engine"); + Assert.notEqual(engine, null); + Assert.ok(engine.isAppProvided, "Should be shown as an app-provided engine"); + + // Check the hidden engine is not loaded. + engine = Services.search.getEngineByName("hidden"); + Assert.equal(engine, null); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_bug930456.js b/toolkit/components/search/tests/xpcshell/test_bug930456.js new file mode 100644 index 0000000000..5985a35523 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_bug930456.js @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function run_test() { + if (XULRuntime.processType == XULRuntime.PROCESS_TYPE_CONTENT) { + Assert.equal(false, "@mozilla.org/browser/search-service;1" in Cc); + } else { + Assert.ok("@mozilla.org/browser/search-service;1" in Cc); + } +} diff --git a/toolkit/components/search/tests/xpcshell/test_bug930456_child.js b/toolkit/components/search/tests/xpcshell/test_bug930456_child.js new file mode 100644 index 0000000000..8540a37f4e --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_bug930456_child.js @@ -0,0 +1,3 @@ +function run_test() { + run_test_in_child("test_bug930456.js"); +} diff --git a/toolkit/components/search/tests/xpcshell/test_config_attribution.js b/toolkit/components/search/tests/xpcshell/test_config_attribution.js new file mode 100644 index 0000000000..9996cc83c4 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_config_attribution.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function setup() { + Region._setHomeRegion("an", false); + await SearchTestUtils.useTestEngines("test-extensions"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function test_send_attribution_request() { + let engine = await Services.search.getEngineByName("Plain"); + Assert.ok( + engine.sendAttributionRequest, + "Should have noted to send the attribution request for Plain" + ); + + engine = await Services.search.getEngineByName("Special"); + Assert.ok( + engine.sendAttributionRequest, + "Should have noted to send the attribution request for Special" + ); + + engine = await Services.search.getEngineByName("Multilocale AN"); + Assert.ok( + !engine.sendAttributionRequest, + "Should not have noted to send the attribution request for Multilocale AN" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_config_engine_params.js b/toolkit/components/search/tests/xpcshell/test_config_engine_params.js new file mode 100644 index 0000000000..521eaf7dfc --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_config_engine_params.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("method-extensions"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function test_get_extension() { + let engine = Services.search.getEngineByName("Get Engine"); + Assert.notEqual(engine, null, "Should have found an engine"); + + let url = engine.wrappedJSObject._getURLOfType(SearchUtils.URL_TYPE.SEARCH); + Assert.equal(url.method, "GET", "Search URLs method is GET"); + + let submission = engine.getSubmission("foo"); + Assert.equal( + submission.uri.spec, + "https://example.com/?config=1&search=foo", + "Search URLs should match" + ); + + let submissionSuggest = engine.getSubmission( + "bar", + SearchUtils.URL_TYPE.SUGGEST_JSON + ); + Assert.equal( + submissionSuggest.uri.spec, + "https://example.com/?config=1&suggest=bar", + "Suggest URLs should match" + ); +}); + +add_task(async function test_post_extension() { + let engine = Services.search.getEngineByName("Post Engine"); + Assert.notEqual(engine, null, "Should have found an engine"); + + let url = engine.wrappedJSObject._getURLOfType(SearchUtils.URL_TYPE.SEARCH); + Assert.equal(url.method, "POST", "Search URLs method is POST"); + + let submission = engine.getSubmission("foo"); + Assert.equal( + submission.uri.spec, + "https://example.com/", + "Search URLs should match" + ); + Assert.equal( + submission.postData.data.data, + "config=1&search=foo", + "Search postData should match" + ); + + let submissionSuggest = engine.getSubmission( + "bar", + SearchUtils.URL_TYPE.SUGGEST_JSON + ); + Assert.equal( + submissionSuggest.uri.spec, + "https://example.com/", + "Suggest URLs should match" + ); + Assert.equal( + submissionSuggest.postData.data.data, + "config=1&suggest=bar", + "Suggest postData should match" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_defaultEngine.js b/toolkit/components/search/tests/xpcshell/test_defaultEngine.js new file mode 100644 index 0000000000..061580d39e --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_defaultEngine.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test that defaultEngine property can be set and yields the proper events and\ + * behavior (search results) + */ + +"use strict"; + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +function promiseDefaultNotification() { + return SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.DEFAULT, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); +} + +add_task(async function test_defaultEngine() { + let search = Services.search; + await search.init(); + + let originalDefault = search.defaultEngine; + + let [engine1, engine2] = await addTestEngines([ + { name: "Test search engine", xmlFileName: "engine.xml" }, + { name: "A second test engine", xmlFileName: "engine2.xml" }, + ]); + + let promise = promiseDefaultNotification(); + search.defaultEngine = engine1; + Assert.equal(await promise, engine1); + Assert.equal(search.defaultEngine, engine1); + + promise = promiseDefaultNotification(); + search.defaultEngine = engine2; + Assert.equal(await promise, engine2); + Assert.equal(search.defaultEngine, engine2); + + promise = promiseDefaultNotification(); + search.defaultEngine = engine1; + Assert.equal(await promise, engine1); + Assert.equal(search.defaultEngine, engine1); + + // Test that hiding the currently-default engine affects the defaultEngine getter + // We fallback first to the original default... + engine1.hidden = true; + Assert.equal(search.defaultEngine, originalDefault); + + // ... and then to the first visible engine in the list, so move our second + // engine to that position. + await search.moveEngine(engine2, 0); + originalDefault.hidden = true; + Assert.equal(search.defaultEngine, engine2); + + // Test that setting defaultEngine to an already-hidden engine works, but + // doesn't change the return value of the getter + promise = promiseDefaultNotification(); + search.defaultEngine = engine1; + Assert.equal(await promise, engine1); + Assert.equal(search.defaultEngine, engine2); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_defaultEngine_fallback.js b/toolkit/components/search/tests/xpcshell/test_defaultEngine_fallback.js new file mode 100644 index 0000000000..15003f1590 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_defaultEngine_fallback.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function setup() { + useHttpServer(); + await SearchTestUtils.useTestEngines(); + + Services.prefs.setCharPref(SearchUtils.BROWSER_SEARCH_PREF + "region", "US"); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + + await AddonTestUtils.promiseStartupManager(); +}); + +function getDefault(privateMode) { + return privateMode + ? Services.search.getDefaultPrivate() + : Services.search.getDefault(); +} + +async function checkBuiltinFallback(privateMode) { + info( + `Testing ${ + privateMode ? "private" : "normal" + } default engine fallback (builtin)` + ); + + Assert.ok((await Services.search.getVisibleEngines()).length > 1); + Assert.ok(Services.search.isInitialized); + + let defaultEngine = await getDefault(privateMode); + await Services.search.removeEngine(defaultEngine); + + Assert.notEqual( + (await getDefault(privateMode)).name, + defaultEngine.name, + "Should have changed the default to a different engine" + ); + Assert.ok(defaultEngine.hidden, "Should have hidden the removed engine"); + + for (let engine of await Services.search.getVisibleEngines()) { + await Services.search.removeEngine(engine); + } + Assert.strictEqual( + (await Services.search.getVisibleEngines()).length, + 0, + "Should have no visible engines left after removal" + ); + + Assert.equal( + (await getDefault(privateMode)).name, + defaultEngine.name, + "Should fallback to the original default engine after removing all engines" + ); + Assert.ok( + !defaultEngine.hidden, + "Should have unhidden the original default engine" + ); + Assert.equal( + (await Services.search.getVisibleEngines()).length, + 1, + "Should now have one engine visible" + ); + if (privateMode) { + // When all engines have been hidden, and the default is re-obtained, + // then it first looks at visible engines, as the private engine has been + // re-established, we get that here. + Assert.equal( + (await getDefault(false)).name, + defaultEngine.name, + "Should still have the correct default in normal mode after adjusting the private mode default" + ); + await Services.search.setDefault( + await Services.search.originalDefaultEngine + ); + } else { + await Services.search.setDefaultPrivate( + await Services.search.originalPrivateDefaultEngine + ); + } + + // Re-enable all engines ready for the next test. + Services.search.restoreDefaultEngines(); +} + +add_task(async function test_default_fallback_builtin() { + await checkBuiltinFallback(false); +}); + +add_task(async function test_default_fallback_builtin_private() { + await checkBuiltinFallback(true); +}); + +async function checkNonBuiltinFallback(privateMode) { + info( + `Testing ${ + privateMode ? "private" : "normal" + } default engine fallback (non-builtin)` + ); + + const [addedEngine] = await addTestEngines([ + { name: "A second test engine", xmlFileName: "engine2.xml" }, + ]); + const defaultEngine = await getDefault(privateMode); + + if (privateMode) { + await Services.search.setDefaultPrivate(addedEngine); + } else { + await Services.search.setDefault(addedEngine); + } + + // Remove the current engine... + await Services.search.removeEngine(addedEngine); + + // ... and verify we've reverted to the normal default engine. + Assert.equal( + (await getDefault(privateMode)).name, + defaultEngine.name, + "Should revert to the original default engine" + ); +} + +add_task(async function test_default_fallback_non_builtin() { + await checkNonBuiltinFallback(false); +}); + +add_task(async function test_default_fallback_non_builtin_private() { + await checkNonBuiltinFallback(true); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_defaultPrivateEngine.js b/toolkit/components/search/tests/xpcshell/test_defaultPrivateEngine.js new file mode 100644 index 0000000000..1aa21ba190 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_defaultPrivateEngine.js @@ -0,0 +1,247 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test that defaultEngine property can be set and yields the proper events and\ + * behavior (search results) + */ + +"use strict"; + +let engine1; +let engine2; +let originalDefault; +let originalPrivateDefault; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines(); + + Services.prefs.setCharPref(SearchUtils.BROWSER_SEARCH_PREF + "region", "US"); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); + + await Services.search.init(); + + originalDefault = Services.search.originalDefaultEngine; + originalPrivateDefault = Services.search.originalPrivateDefaultEngine; + engine1 = Services.search.getEngineByName("engine-rel-searchform-purpose"); + engine2 = Services.search.getEngineByName("engine-chromeicon"); +}); + +add_task(async function test_defaultPrivateEngine() { + Assert.equal( + Services.search.defaultPrivateEngine, + originalPrivateDefault, + "Should have the original private default as the default private engine" + ); + Assert.equal( + Services.search.defaultEngine, + originalDefault, + "Should have the original default as the default engine" + ); + + let promise = promiseDefaultNotification("private"); + Services.search.defaultPrivateEngine = engine1; + Assert.equal( + await promise, + engine1, + "Should have notified setting the private engine to the new one" + ); + Assert.equal( + Services.search.defaultPrivateEngine, + engine1, + "Should have set the private engine to the new one" + ); + Assert.equal( + Services.search.defaultEngine, + originalDefault, + "Should not have changed the original default engine" + ); + promise = promiseDefaultNotification("private"); + await Services.search.setDefaultPrivate(engine2); + Assert.equal( + await promise, + engine2, + "Should have notified setting the private engine to the new one using async api" + ); + Assert.equal( + Services.search.defaultPrivateEngine, + engine2, + "Should have set the private engine to the new one using the async api" + ); + // We use the names here as for some reason the getDefaultPrivate promise + // returns something which is an nsISearchEngine but doesn't compare + // exactly to what engine2 is. + Assert.equal( + (await Services.search.getDefaultPrivate()).name, + engine2.name, + "Should have got the correct private engine with the async api" + ); + Assert.equal( + Services.search.defaultEngine, + originalDefault, + "Should not have changed the original default engine" + ); + + promise = promiseDefaultNotification("private"); + await Services.search.setDefaultPrivate(engine1); + Assert.equal( + await promise, + engine1, + "Should have notified reverting the private engine to the selected one using async api" + ); + Assert.equal( + Services.search.defaultPrivateEngine, + engine1, + "Should have reverted the private engine to the selected one using the async api" + ); + + engine1.hidden = true; + Assert.equal( + Services.search.defaultPrivateEngine, + originalPrivateDefault, + "Should reset to the original default private engine when hiding the default" + ); + Assert.equal( + Services.search.defaultEngine, + originalDefault, + "Should not have changed the original default engine" + ); + + engine1.hidden = false; + Services.search.defaultEngine = engine1; + Assert.equal( + Services.search.defaultPrivateEngine, + originalPrivateDefault, + "Setting the default engine should not affect the private default" + ); + + Services.search.defaultEngine = originalDefault; +}); + +add_task(async function test_defaultPrivateEngine_turned_off() { + Services.search.defaultEngine = originalDefault; + Services.search.defaultPrivateEngine = engine1; + + let promise = promiseDefaultNotification("private"); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + false + ); + Assert.equal( + await promise, + originalDefault, + "Should have notified setting the first engine correctly." + ); + + promise = promiseDefaultNotification("normal"); + let privatePromise = promiseDefaultNotification("private"); + Services.search.defaultEngine = engine1; + Assert.equal( + await promise, + engine1, + "Should have notified setting the first engine correctly." + ); + Assert.equal( + await privatePromise, + engine1, + "Should have notified setting of the private engine as well." + ); + Assert.equal( + Services.search.defaultPrivateEngine, + engine1, + "Should be set to the first engine correctly" + ); + Assert.equal( + Services.search.defaultEngine, + engine1, + "Should keep the default engine in sync with the pref off" + ); + promise = promiseDefaultNotification("private"); + Services.search.defaultPrivateEngine = engine2; + Assert.equal( + await promise, + engine2, + "Should have notified setting the second engine correctly." + ); + Assert.equal( + Services.search.defaultPrivateEngine, + engine2, + "Should be set to the second engine correctly" + ); + Assert.equal( + Services.search.defaultEngine, + engine1, + "Should not change the normal mode default engine" + ); + Assert.equal( + Services.prefs.getBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + false + ), + true, + "Should have set the separate private default pref to true" + ); + promise = promiseDefaultNotification("private"); + Services.search.defaultPrivateEngine = engine1; + Assert.equal( + await promise, + engine1, + "Should have notified resetting to the first engine again" + ); + Assert.equal( + Services.search.defaultPrivateEngine, + engine1, + "Should be reset to the first engine again" + ); + Assert.equal( + Services.search.defaultEngine, + engine1, + "Should keep the default engine in sync with the pref off" + ); +}); + +add_task(async function test_defaultPrivateEngine_ui_turned_off() { + engine1.hidden = false; + engine2.hidden = false; + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + + Services.search.defaultEngine = engine2; + Services.search.defaultPrivateEngine = engine1; + + let promise = promiseDefaultNotification("private"); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + false + ); + Assert.equal( + await promise, + engine2, + "Should have notified for resetting of the private pref." + ); + + promise = promiseDefaultNotification("normal"); + Services.search.defaultPrivateEngine = engine1; + Assert.equal( + await promise, + engine1, + "Should have notified setting the first engine correctly." + ); + Assert.equal( + Services.search.defaultPrivateEngine, + engine1, + "Should be set to the first engine correctly" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_alias.js b/toolkit/components/search/tests/xpcshell/test_engine_alias.js new file mode 100644 index 0000000000..a742e33097 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_alias.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +SearchTestUtils.initXPCShellAddonManager(this); + +const NAME = "Test Alias Engine"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + let settingsFileWritten = promiseAfterSettings(); + await Services.search.init(); + await settingsFileWritten; +}); + +add_task(async function upgrade_with_configuration_change_test() { + let settingsFileWritten = promiseAfterSettings(); + let extension = await SearchTestUtils.installSearchExtension({ + name: NAME, + keyword: "testalias", + }); + await extension.awaitStartup(); + await settingsFileWritten; + + let engine = await Services.search.getEngineByAlias("testalias"); + Assert.equal(engine?.name, NAME, "Engine can be fetched by alias"); + + // Restart the search service but not the AddonManager, we will + // load the engines from settings. + Services.search.wrappedJSObject.reset(); + await Services.search.init(); + + engine = await Services.search.getEngineByAlias("testalias"); + Assert.equal(engine?.name, NAME, "Engine can be fetched by alias"); + + await extension.unload(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_multiple_alias.js b/toolkit/components/search/tests/xpcshell/test_engine_multiple_alias.js new file mode 100644 index 0000000000..04119df6e4 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_multiple_alias.js @@ -0,0 +1,28 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +SearchTestUtils.initXPCShellAddonManager(this); + +const NAME = "Test Alias Engine"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function upgrade_with_configuration_change_test() { + let settingsFileWritten = promiseAfterSettings(); + let extension = await SearchTestUtils.installSearchExtension({ + name: NAME, + keyword: [" test", "alias "], + }); + await extension.awaitStartup(); + await settingsFileWritten; + + let engine = await Services.search.getEngineByAlias("test"); + Assert.equal(engine?.name, NAME, "Can be fetched by either alias"); + engine = await Services.search.getEngineByAlias("alias"); + Assert.equal(engine?.name, NAME, "Can be fetched by either alias"); + + await extension.unload(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_selector.js b/toolkit/components/search/tests/xpcshell/test_engine_selector.js new file mode 100644 index 0000000000..def9393d23 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_selector.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", +}); + +const TEST_CONFIG = [ + { + engineName: "aol", + orderHint: 500, + webExtension: { + locales: ["default"], + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + { + included: { regions: ["us"] }, + webExtension: { + locales: ["$USER_LOCALE"], + }, + }, + ], + }, + { + engineName: "lycos", + orderHint: 1000, + default: "yes", + appliesTo: [ + { + included: { everywhere: true }, + excluded: { locales: { matches: ["zh-CN"] } }, + }, + ], + }, + { + engineName: "altavista", + orderHint: 2000, + defaultPrivate: "yes", + appliesTo: [ + { + included: { locales: { matches: ["en-US"] } }, + }, + { + included: { regions: ["default"] }, + }, + ], + }, + { + engineName: "excite", + default: "yes-if-no-other", + appliesTo: [ + { + included: { everywhere: true }, + excluded: { regions: ["us"] }, + }, + { + included: { everywhere: true }, + experiment: "acohortid", + }, + ], + }, + { + engineName: "askjeeves", + }, +]; + +const engineSelector = new SearchEngineSelector(); + +add_task(async function() { + const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + sinon.stub(settings, "get").returns(TEST_CONFIG); + + let { + engines, + privateDefault, + } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "us", + }); + Assert.equal( + privateDefault.engineName, + "altavista", + "Should set altavista as privateDefault" + ); + let names = engines.map(obj => obj.engineName); + Assert.deepEqual(names, ["lycos", "altavista", "aol"], "Correct order"); + Assert.equal( + engines[2].webExtension.locale, + "en-US", + "Subsequent matches in applies to can override default" + ); + + ({ engines, privateDefault } = await engineSelector.fetchEngineConfiguration({ + locale: "zh-CN", + region: "kz", + })); + Assert.equal(engines.length, 2, "Correct engines are returns"); + Assert.equal(privateDefault, null, "There should be no privateDefault"); + names = engines.map(obj => obj.engineName); + Assert.deepEqual( + names, + ["excite", "aol"], + "The engines should be in the correct order" + ); + + ({ engines, privateDefault } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "us", + experiment: "acohortid", + })); + Assert.deepEqual( + engines.map(obj => obj.engineName), + ["lycos", "altavista", "aol", "excite"], + "Engines are in the correct order and include the experiment engine" + ); + + ({ engines, privateDefault } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "default", + experiment: "acohortid", + })); + Assert.deepEqual( + engines.map(obj => obj.engineName), + ["lycos", "altavista", "aol", "excite"], + "The engines should be in the correct order" + ); + Assert.equal( + privateDefault.engineName, + "altavista", + "Should set altavista as privateDefault" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_selector_application.js b/toolkit/components/search/tests/xpcshell/test_engine_selector_application.js new file mode 100644 index 0000000000..5f38230dac --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_application.js @@ -0,0 +1,115 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", +}); + +const TEST_CONFIG = [ + { + webExtension: { + id: "aol@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + default: "yes-if-no-other", + }, + { + webExtension: { + id: "lycos@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + application: { + channel: ["nightly"], + }, + }, + ], + default: "yes", + }, + { + webExtension: { + id: "altavista@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + application: { + channel: ["nightly", "esr"], + }, + }, + ], + }, + { + webExtension: { + id: "excite@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + { + included: { everywhere: true }, + application: { + channel: ["release"], + }, + default: "yes", + }, + ], + }, +]; + +const expectedEnginesPerChannel = { + default: ["aol@example.com", "excite@example.com"], + nightly: [ + "lycos@example.com", + "aol@example.com", + "altavista@example.com", + "excite@example.com", + ], + beta: ["aol@example.com", "excite@example.com"], + release: ["excite@example.com", "aol@example.com"], + esr: ["aol@example.com", "altavista@example.com", "excite@example.com"], +}; + +const expectedDefaultEngine = { + default: "aol@example.com", + nightly: "lycos@example.com", + beta: "aol@example.com", + release: "excite@example.com", + esr: "aol@example.com", +}; + +const engineSelector = new SearchEngineSelector(); + +add_task(async function test_engine_selector_channels() { + const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + sinon.stub(settings, "get").returns(TEST_CONFIG); + + for (let [channel, expected] of Object.entries(expectedEnginesPerChannel)) { + const { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "us", + channel, + }); + + const engineIds = engines.map(obj => obj.webExtension.id); + Assert.deepEqual( + engineIds, + expected, + `Should have the expected engines for channel "${channel}"` + ); + + Assert.equal( + engineIds[0], + expectedDefaultEngine[channel], + `Should have the correct default for channel "${channel}"` + ); + } +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_selector_application_distribution.js b/toolkit/components/search/tests/xpcshell/test_engine_selector_application_distribution.js new file mode 100644 index 0000000000..1d37e62fcb --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_application_distribution.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", +}); + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const CONFIG = [ + { + webExtension: { + id: "aol@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + default: "yes-if-no-other", + }, + { + webExtension: { + id: "excite@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + // Test with a application/distributions section present but an + // empty list. + application: { + distributions: [], + }, + }, + ], + }, + { + webExtension: { + id: "lycos@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + application: { + distributions: ["cake"], + }, + }, + ], + default: "yes", + }, + { + webExtension: { + id: "altavista@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + application: { + excludedDistributions: ["apples"], + }, + }, + ], + }, +]; + +const engineSelector = new SearchEngineSelector(); +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data", null, CONFIG); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_no_distribution_preference() { + let { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "default", + region: "default", + channel: "", + distroID: "", + }); + const engineIds = engines.map(obj => obj.webExtension.id); + Assert.deepEqual( + engineIds, + ["aol@example.com", "excite@example.com", "altavista@example.com"], + `Should have the expected engines for a normal build.` + ); +}); + +add_task(async function test_distribution_included() { + let { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "default", + region: "default", + channel: "", + distroID: "cake", + }); + const engineIds = engines.map(obj => obj.webExtension.id); + Assert.deepEqual( + engineIds, + [ + "lycos@example.com", + "aol@example.com", + "excite@example.com", + "altavista@example.com", + ], + `Should have the expected engines for the "cake" distribution.` + ); +}); + +add_task(async function test_distribution_excluded() { + let { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "default", + region: "default", + channel: "", + distroID: "apples", + }); + const engineIds = engines.map(obj => obj.webExtension.id); + Assert.deepEqual( + engineIds, + ["aol@example.com", "excite@example.com"], + `Should have the expected engines for the "apples" distribution.` + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_selector_application_name.js b/toolkit/components/search/tests/xpcshell/test_engine_selector_application_name.js new file mode 100644 index 0000000000..4e31eb77c8 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_application_name.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", +}); + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const CONFIG = [ + { + webExtension: { + id: "aol@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + default: "yes-if-no-other", + }, + { + webExtension: { + id: "lycos@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + application: { + name: ["firefox"], + }, + }, + ], + default: "yes", + }, + { + webExtension: { + id: "altavista@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + application: { + name: ["fenix"], + }, + }, + ], + }, + { + webExtension: { + id: "excite@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + application: { + name: ["firefox"], + minVersion: "10", + maxVersion: "30", + }, + default: "yes", + }, + ], + }, +]; + +function fetchWithConfig(name, version) { + Services.appinfo = { name, version }; + return engineSelector.fetchEngineConfiguration({ + locale: "default", + region: "default", + }); +} + +const engineSelector = new SearchEngineSelector(); + +const tests = [ + { + name: "Firefox", + version: "1", + expected: ["lycos@example.com", "aol@example.com"], + }, + { + name: "Firefox", + version: "20", + expected: ["lycos@example.com", "aol@example.com", "excite@example.com"], + }, + { + name: "Fenix", + version: "20", + expected: ["aol@example.com", "altavista@example.com"], + }, + { + name: "Firefox", + version: "31", + expected: ["lycos@example.com", "aol@example.com"], + }, + { + name: "Firefox", + version: "30", + expected: ["lycos@example.com", "aol@example.com", "excite@example.com"], + }, + { + name: "Firefox", + version: "10", + expected: ["lycos@example.com", "aol@example.com", "excite@example.com"], + }, +]; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data", null, CONFIG); + await AddonTestUtils.promiseStartupManager(); + + let confUrl = `data:application/json,${JSON.stringify(CONFIG)}`; + Services.prefs.setStringPref("search.config.url", confUrl); +}); + +add_task(async function test_application_name() { + for (const { name, version, expected } of tests) { + Services.appinfo = { name, version }; + let { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "default", + region: "default", + }); + const engineIds = engines.map(obj => obj.webExtension.id); + Assert.deepEqual( + engineIds, + expected, + `Should have the expected engines for app: "${name}" + and version: "${version}"` + ); + } +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_selector_order.js b/toolkit/components/search/tests/xpcshell/test_engine_selector_order.js new file mode 100644 index 0000000000..e2e278c93a --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_order.js @@ -0,0 +1,138 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", +}); + +/** + * This constant defines the tests for the order. The input is an array of + * engines that will be constructed. The engine definitions are arrays with + * fields in order: + * name, orderHint, default, defaultPrivate + * + * The expected is an array of engine names. + */ +const TESTS = [ + { + // Basic tests to ensure correct order for default engine. + input: [ + ["A", 750, "no", "no"], + ["B", 3000, "no", "no"], + ["C", 2000, "yes", "no"], + ["D", 1000, "yes-if-no-other", "no"], + ["E", 500, "no", "no"], + ], + expected: ["C", "B", "D", "A", "E"], + expectedPrivate: undefined, + }, + { + input: [ + ["A", 750, "no", "no"], + ["B", 3000, "no", "no"], + ["C", 2000, "yes-if-no-other", "no"], + ["D", 1000, "yes", "no"], + ["E", 500, "no", "no"], + ], + expected: ["D", "B", "C", "A", "E"], + expectedPrivate: undefined, + }, + // Check that yes-if-no-other works correctly. + { + input: [ + ["A", 750, "no", "no"], + ["B", 3000, "no", "no"], + ["C", 2000, "no", "no"], + ["D", 1000, "yes-if-no-other", "no"], + ["E", 500, "no", "no"], + ], + expected: ["D", "B", "C", "A", "E"], + expectedPrivate: undefined, + }, + // Basic tests to ensure correct order with private engine. + { + input: [ + ["A", 750, "no", "no"], + ["B", 3000, "yes-if-no-other", "no"], + ["C", 2000, "no", "yes"], + ["D", 1000, "yes", "yes-if-no-other"], + ["E", 500, "no", "no"], + ], + expected: ["D", "C", "B", "A", "E"], + expectedPrivate: "C", + }, + { + input: [ + ["A", 750, "no", "yes-if-no-other"], + ["B", 3000, "yes-if-no-other", "no"], + ["C", 2000, "no", "yes"], + ["D", 1000, "yes", "no"], + ["E", 500, "no", "no"], + ], + expected: ["D", "C", "B", "A", "E"], + expectedPrivate: "C", + }, + // Private engine test for yes-if-no-other. + { + input: [ + ["A", 750, "no", "yes-if-no-other"], + ["B", 3000, "yes-if-no-other", "no"], + ["C", 2000, "no", "no"], + ["D", 1000, "yes", "no"], + ["E", 500, "no", "no"], + ], + expected: ["D", "A", "B", "C", "E"], + expectedPrivate: "A", + }, +]; + +function getConfigData(testInput) { + return testInput.map(info => ({ + engineName: info[0], + orderHint: info[1], + default: info[2], + defaultPrivate: info[3], + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + })); +} + +const engineSelector = new SearchEngineSelector(); + +add_task(async function() { + const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + const getStub = sinon.stub(settings, "get"); + + let i = 0; + for (const test of TESTS) { + // Remove the existing configuration and update the stub to return the data + // for this test. + delete engineSelector._configuration; + getStub.returns(getConfigData(test.input)); + + const { + engines, + privateDefault, + } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "us", + }); + + let names = engines.map(obj => obj.engineName); + Assert.deepEqual( + names, + test.expected, + `Should have the correct order for the engines: test ${i}` + ); + Assert.equal( + privateDefault && privateDefault.engineName, + test.expectedPrivate, + `Should have the correct selection for the private engine: test ${i++}` + ); + } +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_selector_override.js b/toolkit/components/search/tests/xpcshell/test_engine_selector_override.js new file mode 100644 index 0000000000..4727712a59 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_override.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", +}); + +const TEST_CONFIG = [ + { + webExtension: { + id: "aol@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + { + override: true, + application: { + distributions: ["distro1"], + }, + params: { + searchUrlGetParams: [ + { + name: "field-keywords", + value: "{searchTerms}", + }, + ], + }, + }, + ], + default: "yes-if-no-other", + }, + { + webExtension: { + id: "lycos@example.com", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + { + override: true, + experiment: "experiment1", + sendAttributionRequest: true, + }, + ], + default: "yes", + }, +]; + +const engineSelector = new SearchEngineSelector(); + +add_task(async function setup() { + const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + sinon.stub(settings, "get").returns(TEST_CONFIG); +}); + +add_task(async function test_engine_selector_defaults() { + // Check that with no override sections matching, we have no overrides active. + const { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "us", + }); + + let engine = engines.find(e => e.webExtension.id == "aol@example.com"); + + Assert.ok( + !("params" in engine), + "Should not have overriden the parameters of the engine." + ); + + engine = engines.find(e => e.webExtension.id == "lycos@example.com"); + + Assert.ok( + !("sendAttributionRequest" in engine), + "Should have overriden the sendAttributionRequest field of the engine." + ); +}); + +add_task(async function test_engine_selector_override_distributions() { + const { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "us", + distroID: "distro1", + }); + + let engine = engines.find(e => e.webExtension.id == "aol@example.com"); + + Assert.deepEqual( + engine.params, + { + searchUrlGetParams: [ + { + name: "field-keywords", + value: "{searchTerms}", + }, + ], + }, + "Should have overriden the parameters of the engine." + ); +}); + +add_task(async function test_engine_selector_override_experiments() { + const { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "us", + experiment: "experiment1", + }); + + let engine = engines.find(e => e.webExtension.id == "lycos@example.com"); + + Assert.equal( + engine.sendAttributionRequest, + true, + "Should have overriden the sendAttributionRequest field of the engine." + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_selector_remote_settings.js b/toolkit/components/search/tests/xpcshell/test_engine_selector_remote_settings.js new file mode 100644 index 0000000000..7f21a73d21 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_remote_settings.js @@ -0,0 +1,343 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + Promise: "resource://gre/modules/Promise.jsm", + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.jsm", +}); + +const TEST_CONFIG = [ + { + engineName: "aol", + orderHint: 500, + webExtension: { + locales: ["default"], + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + { + included: { regions: ["us"] }, + webExtension: { + locales: ["$USER_LOCALE"], + }, + }, + ], + }, + { + engineName: "lycos", + orderHint: 1000, + default: "yes", + appliesTo: [ + { + included: { everywhere: true }, + excluded: { locales: { matches: ["zh-CN"] } }, + }, + ], + }, + { + engineName: "altavista", + orderHint: 2000, + defaultPrivate: "yes", + appliesTo: [ + { + included: { locales: { matches: ["en-US"] } }, + }, + { + included: { regions: ["default"] }, + }, + ], + }, + { + engineName: "excite", + default: "yes-if-no-other", + appliesTo: [ + { + included: { everywhere: true }, + excluded: { regions: ["us"] }, + }, + { + included: { everywhere: true }, + cohort: "acohortid", + }, + ], + }, + { + engineName: "askjeeves", + }, +]; + +let getStub; + +add_task(async function setup() { + const searchConfigSettings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + getStub = sinon.stub(searchConfigSettings, "get"); + + // We expect this error from remove settings as we're invalidating the + // signature. + consoleAllowList.push("Invalid content signature (abc)"); + // We also test returning an empty configuration. + consoleAllowList.push("Received empty search configuration"); +}); + +add_task(async function test_selector_basic_get() { + const listenerSpy = sinon.spy(); + const engineSelector = new SearchEngineSelector(listenerSpy); + getStub.onFirstCall().returns(TEST_CONFIG); + + const { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }); + + Assert.deepEqual( + engines.map(e => e.engineName), + ["lycos", "altavista", "aol", "excite"], + "Should have obtained the correct data from the database." + ); + Assert.ok(listenerSpy.notCalled, "Should not have called the listener"); +}); + +add_task(async function test_selector_get_reentry() { + const listenerSpy = sinon.spy(); + const engineSelector = new SearchEngineSelector(listenerSpy); + let promise = Promise.defer(); + getStub.resetHistory(); + getStub.onFirstCall().returns(promise.promise); + delete engineSelector._configuration; + + let firstResult; + let secondResult; + + const firstCallPromise = engineSelector + .fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }) + .then(result => (firstResult = result.engines)); + + const secondCallPromise = engineSelector + .fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }) + .then(result => (secondResult = result.engines)); + + Assert.strictEqual( + firstResult, + undefined, + "Should not have returned the first result yet." + ); + + Assert.strictEqual( + secondResult, + undefined, + "Should not have returned the second result yet." + ); + + promise.resolve(TEST_CONFIG); + + await Promise.all([firstCallPromise, secondCallPromise]); + Assert.deepEqual( + firstResult.map(e => e.engineName), + ["lycos", "altavista", "aol", "excite"], + "Should have returned the correct data to the first call" + ); + + Assert.deepEqual( + secondResult.map(e => e.engineName), + ["lycos", "altavista", "aol", "excite"], + "Should have returned the correct data to the second call" + ); + Assert.ok(listenerSpy.notCalled, "Should not have called the listener"); +}); + +add_task(async function test_selector_config_update() { + const listenerSpy = sinon.spy(); + const engineSelector = new SearchEngineSelector(listenerSpy); + getStub.resetHistory(); + getStub.onFirstCall().returns(TEST_CONFIG); + + const { engines } = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }); + + Assert.deepEqual( + engines.map(e => e.engineName), + ["lycos", "altavista", "aol", "excite"], + "Should have got the correct configuration" + ); + + Assert.ok(listenerSpy.notCalled, "Should not have called the listener yet"); + + const NEW_DATA = [ + { + default: "yes", + engineName: "askjeeves", + appliesTo: [{ included: { everywhere: true } }], + schema: 1553857697843, + last_modified: 1553859483588, + }, + ]; + + getStub.resetHistory(); + getStub.onFirstCall().returns(NEW_DATA); + await RemoteSettings(SearchUtils.SETTINGS_KEY).emit("sync", { + data: { + current: NEW_DATA, + }, + }); + + Assert.ok(listenerSpy.called, "Should have called the listener"); + + const result = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }); + + Assert.deepEqual( + result.engines.map(e => e.engineName), + ["askjeeves"], + "Should have updated the configuration with the new data" + ); +}); + +add_task(async function test_selector_db_modification() { + const engineSelector = new SearchEngineSelector(); + // Fill the database with some values that we can use to test that it is cleared. + const db = await RemoteSettings(SearchUtils.SETTINGS_KEY).db; + await db.importChanges( + {}, + 42, + [ + { + id: "85e1f268-9ca5-4b52-a4ac-922df5c07264", + default: "yes", + engineName: "askjeeves", + appliesTo: [{ included: { everywhere: true } }], + }, + ], + { clear: true } + ); + + // Stub the get() so that the first call simulates a signature error, and + // the second simulates success reading from the dump. + getStub.resetHistory(); + getStub + .onFirstCall() + .rejects(new RemoteSettingsClient.InvalidSignatureError("abc")); + getStub.onSecondCall().returns(TEST_CONFIG); + + let result = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }); + + Assert.ok( + getStub.calledTwice, + "Should have called the get() function twice." + ); + + const databaseEntries = await db.list(); + Assert.equal(databaseEntries.length, 0, "Should have cleared the database."); + + Assert.deepEqual( + result.engines.map(e => e.engineName), + ["lycos", "altavista", "aol", "excite"], + "Should have returned the correct data." + ); +}); + +add_task(async function test_selector_db_modification_never_succeeds() { + const engineSelector = new SearchEngineSelector(); + // Fill the database with some values that we can use to test that it is cleared. + const db = RemoteSettings(SearchUtils.SETTINGS_KEY).db; + await db.importChanges( + {}, + 42, + [ + { + id: "b70edfdd-1c3f-4b7b-ab55-38cb048636c0", + default: "yes", + engineName: "askjeeves", + appliesTo: [{ included: { everywhere: true } }], + }, + ], + { + clear: true, + } + ); + + // Now simulate the condition where for some reason we never get a + // valid result. + getStub.reset(); + getStub.rejects(new RemoteSettingsClient.InvalidSignatureError("abc")); + + await Assert.rejects( + engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }), + ex => ex.result == Cr.NS_ERROR_UNEXPECTED, + "Should have rejected loading the engine configuration" + ); + + Assert.ok( + getStub.calledTwice, + "Should have called the get() function twice." + ); + + const databaseEntries = await db.list(); + Assert.equal(databaseEntries.length, 0, "Should have cleared the database."); +}); + +add_task(async function test_empty_results() { + // Check that returning an empty result re-tries. + const engineSelector = new SearchEngineSelector(); + // Fill the database with some values that we can use to test that it is cleared. + const db = await RemoteSettings(SearchUtils.SETTINGS_KEY).db; + await db.importChanges( + {}, + 42, + [ + { + id: "df5655ca-e045-4f8c-a7ee-047eeb654722", + default: "yes", + engineName: "askjeeves", + appliesTo: [{ included: { everywhere: true } }], + }, + ], + { + clear: true, + } + ); + + // Stub the get() so that the first call simulates an empty database, and + // the second simulates success reading from the dump. + getStub.resetHistory(); + getStub.onFirstCall().returns([]); + getStub.onSecondCall().returns(TEST_CONFIG); + + let result = await engineSelector.fetchEngineConfiguration({ + locale: "en-US", + region: "default", + }); + + Assert.ok( + getStub.calledTwice, + "Should have called the get() function twice." + ); + + const databaseEntries = await db.list(); + Assert.equal(databaseEntries.length, 0, "Should have cleared the database."); + + Assert.deepEqual( + result.engines.map(e => e.engineName), + ["lycos", "altavista", "aol", "excite"], + "Should have returned the correct data." + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_engine_set_alias.js b/toolkit/components/search/tests/xpcshell/test_engine_set_alias.js new file mode 100644 index 0000000000..20bdf429e5 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_engine_set_alias.js @@ -0,0 +1,104 @@ +"use strict"; + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function test_engine_set_alias() { + info("Set engine alias"); + let [engine1] = await addTestEngines([ + { + name: "bacon", + details: { + alias: "b", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.test/find", + }, + }, + ]); + Assert.ok(engine1.aliases.includes("b")); + engine1.alias = "a"; + Assert.equal(engine1.alias, "a"); + await Services.search.removeEngine(engine1); +}); + +add_task(async function test_engine_set_alias_with_left_space() { + info("Set engine alias with left space"); + let [engine2] = await addTestEngines([ + { + name: "bacon", + details: { + alias: " a", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.test/find", + }, + }, + ]); + Assert.ok(engine2.aliases.includes("a")); + engine2.alias = " c"; + Assert.equal(engine2.alias, "c"); + await Services.search.removeEngine(engine2); +}); + +add_task(async function test_engine_set_alias_with_right_space() { + info("Set engine alias with right space"); + let [engine3] = await addTestEngines([ + { + name: "bacon", + details: { + alias: "c ", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.test/find", + }, + }, + ]); + Assert.ok(engine3.aliases.includes("c")); + engine3.alias = "o "; + Assert.equal(engine3.alias, "o"); + await Services.search.removeEngine(engine3); +}); + +add_task(async function test_engine_set_alias_with_right_left_space() { + info("Set engine alias with left and right space"); + let [engine4] = await addTestEngines([ + { + name: "bacon", + details: { + alias: " o ", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.test/find", + }, + }, + ]); + Assert.ok(engine4.aliases.includes("o")); + engine4.alias = " n "; + Assert.equal(engine4.alias, "n"); + await Services.search.removeEngine(engine4); +}); + +add_task(async function test_engine_set_alias_with_space() { + info("Set engine alias with space"); + let [engine5] = await addTestEngines([ + { + name: "bacon", + details: { + alias: " ", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.test/find", + }, + }, + ]); + Assert.equal(engine5.alias, null); + engine5.alias = "b"; + Assert.equal(engine5.alias, "b"); + engine5.alias = " "; + Assert.equal(engine5.alias, null); + await Services.search.removeEngine(engine5); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_getSubmission_encoding.js b/toolkit/components/search/tests/xpcshell/test_getSubmission_encoding.js new file mode 100644 index 0000000000..891a0de22d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_getSubmission_encoding.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +SearchTestUtils.initXPCShellAddonManager(this); + +const prefix = "https://example.com/?sourceId=Mozilla-search&search="; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("simple-engines"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +function testEncode(engine, charset, query, expected) { + engine.wrappedJSObject._queryCharset = charset; + + Assert.equal( + engine.getSubmission(query).uri.spec, + prefix + expected, + `Should have correctly encoded for ${charset}` + ); +} + +add_task(async function test_getSubmission_encoding() { + let engine = await Services.search.getEngineByName("Simple Engine"); + + testEncode(engine, "UTF-8", "caff\u00E8", "caff%C3%A8"); + testEncode(engine, "windows-1252", "caff\u00E8", "caff%E8"); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_getSubmission_params.js b/toolkit/components/search/tests/xpcshell/test_getSubmission_params.js new file mode 100644 index 0000000000..e20b57ff64 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_getSubmission_params.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +SearchTestUtils.initXPCShellAddonManager(this); + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("simple-engines"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +const searchTerms = "fxsearch"; +function checkSubstitution(url, prefix, engine, template, expected) { + url.template = prefix + template; + equal(engine.getSubmission(searchTerms).uri.spec, prefix + expected); +} + +add_task(async function test_paramSubstitution() { + let prefix = "https://example.com/?sourceId=Mozilla-search&search="; + let engine = await Services.search.getEngineByName("Simple Engine"); + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.getSubmission("foo", engine).uri.spec, prefix + "foo"); + // Reset the engine parameters so we can have a clean template to use for + // the subsequent tests. + url.params = []; + + let check = checkSubstitution.bind(this, url, prefix, engine); + + // The same parameter can be used more than once. + check("{searchTerms}/{searchTerms}", searchTerms + "/" + searchTerms); + + // Optional parameters are replaced if we known them. + check("{searchTerms?}", searchTerms); + check("{unknownOptional?}", ""); + check("{unknownRequired}", "{unknownRequired}"); + + check("{language}", Services.locale.requestedLocale); + check("{language?}", Services.locale.requestedLocale); + + engine.wrappedJSObject._queryCharset = "UTF-8"; + check("{inputEncoding}", "UTF-8"); + check("{inputEncoding?}", "UTF-8"); + check("{outputEncoding}", "UTF-8"); + check("{outputEncoding?}", "UTF-8"); + + // 'Unsupported' parameters with hard coded values used only when the parameter is required. + check("{count}", "20"); + check("{count?}", ""); + check("{startIndex}", "1"); + check("{startIndex?}", ""); + check("{startPage}", "1"); + check("{startPage?}", ""); + + check("{moz:distributionID}", ""); + Services.prefs.setCharPref("browser.search.distributionID", "xpcshell"); + check("{moz:distributionID}", "xpcshell"); + Services.prefs.setBoolPref("browser.search.official", true); + check("{moz:official}", "official"); + Services.prefs.setBoolPref("browser.search.official", false); + check("{moz:official}", "unofficial"); + check("{moz:locale}", Services.locale.requestedLocale); + + url.template = prefix + "{moz:date}"; + let params = new URLSearchParams(engine.getSubmission(searchTerms).uri.query); + Assert.ok(params.has("search"), "Should have a search option"); + + let [, year, month, day, hour] = params + .get("search") + .match(/^(\d{4})(\d{2})(\d{2})(\d{2})/); + let date = new Date(year, month - 1, day, hour); + + // We check the time is within an hour of now as the parameter is only + // precise to an hour. Checking the difference also should cope with date + // changes etc. + let difference = Date.now() - date; + Assert.lessOrEqual( + difference, + 60 * 60 * 1000, + "Should have set the date within an hour" + ); + Assert.greaterOrEqual(difference, 0, "Should not have a time in the past."); +}); + +add_task(async function test_mozParamsFailForNonAppProvided() { + let extension = await SearchTestUtils.installSearchExtension(); + + let prefix = "https://example.com/?q="; + let engine = await Services.search.getEngineByName("Example"); + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.getSubmission("foo", engine).uri.spec, prefix + "foo"); + // Reset the engine parameters so we can have a clean template to use for + // the subsequent tests. + url.params = []; + + let check = checkSubstitution.bind(this, url, prefix, engine); + + // Test moz: parameters (only supported for built-in engines, ie _isDefault == true). + check("{moz:distributionID}", "{moz:distributionID}"); + check("{moz:official}", "{moz:official}"); + check("{moz:locale}", "{moz:locale}"); + + await extension.unload(); + await promiseAfterSettings(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_identifiers.js b/toolkit/components/search/tests/xpcshell/test_identifiers.js new file mode 100644 index 0000000000..138417bc12 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_identifiers.js @@ -0,0 +1,59 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test of a search engine's identifier. + */ + +"use strict"; + +const SEARCH_APP_DIR = 1; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("simple-engines"); + await AddonTestUtils.promiseStartupManager(); + + const result = await Services.search.init(); + Assert.ok( + Components.isSuccessCode(result), + "Should have initialized the service" + ); + + await installTestEngine(); +}); + +function checkIdentifier(engineName, expectedIdentifier, expectedTelemetryId) { + const engine = Services.search.getEngineByName(engineName); + Assert.ok( + engine instanceof Ci.nsISearchEngine, + "Should be derived from nsISearchEngine" + ); + + Assert.equal( + engine.identifier, + expectedIdentifier, + "Should have the correct identifier" + ); + + Assert.equal( + engine.telemetryId, + expectedTelemetryId, + "Should have the correct telemetry Id" + ); +} + +add_task(async function test_from_profile() { + // An engine loaded from the profile directory won't have an identifier, + // because it's not built-in. + checkIdentifier(kTestEngineName, null, `other-${kTestEngineName}`); +}); + +add_task(async function test_from_telemetry_id() { + checkIdentifier("basic", "telemetry", "telemetry"); +}); + +add_task(async function test_from_webextension_id() { + // If not specified, the telemetry Id is derived from the WebExtension prefix, + // it should not use the WebExtension display name. + checkIdentifier("Simple Engine", "simple", "simple"); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_ignorelist.js b/toolkit/components/search/tests/xpcshell/test_ignorelist.js new file mode 100644 index 0000000000..b7b3e85f74 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_ignorelist.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kSearchEngineID1 = "ignorelist_test_engine1"; +const kSearchEngineID2 = "ignorelist_test_engine2"; +const kSearchEngineID3 = "ignorelist_test_engine3"; +const kSearchEngineURL1 = + "http://example.com/?search={searchTerms}&ignore=true"; +const kSearchEngineURL2 = + "http://example.com/?search={searchTerms}&IGNORE=TRUE"; +const kSearchEngineURL3 = "http://example.com/?search={searchTerms}"; +const kExtensionID = "searchignore@mozilla.com"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_ignoreList() { + await setupRemoteSettings(); + + Assert.ok( + !Services.search.isInitialized, + "Search service should not be initialized to begin with." + ); + + let updatePromise = SearchTestUtils.promiseSearchNotification( + "settings-update-complete" + ); + + await Services.search.addEngineWithDetails(kSearchEngineID1, { + method: "get", + template: kSearchEngineURL1, + }); + + await updatePromise; + + let engine = Services.search.getEngineByName(kSearchEngineID1); + Assert.equal( + engine, + null, + "Engine with ignored search params should not exist" + ); + + await Services.search.addEngineWithDetails(kSearchEngineID2, { + method: "get", + template: kSearchEngineURL2, + }); + + // An ignored engine shouldn't be available at all + engine = Services.search.getEngineByName(kSearchEngineID2); + Assert.equal( + engine, + null, + "Engine with ignored search params of a different case should not exist" + ); + + await Services.search.addEngineWithDetails(kSearchEngineID3, { + method: "get", + template: kSearchEngineURL3, + extensionID: kExtensionID, + }); + + // An ignored engine shouldn't be available at all + engine = Services.search.getEngineByName(kSearchEngineID3); + Assert.equal( + engine, + null, + "Engine with ignored extension id should not exist" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_ignorelist_update.js b/toolkit/components/search/tests/xpcshell/test_ignorelist_update.js new file mode 100644 index 0000000000..6666319b38 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_ignorelist_update.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kSearchEngineID1 = "ignorelist_test_engine1"; +const kSearchEngineID2 = "ignorelist_test_engine2"; +const kSearchEngineID3 = "ignorelist_test_engine3"; +const kSearchEngineURL1 = + "http://example.com/?search={searchTerms}&ignore=true"; +const kSearchEngineURL2 = + "http://example.com/?search={searchTerms}&IGNORE=TRUE"; +const kSearchEngineURL3 = "http://example.com/?search={searchTerms}"; +const kExtensionID = "searchignore@mozilla.com"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_ignoreList() { + Assert.ok( + !Services.search.isInitialized, + "Search service should not be initialized to begin with." + ); + + let updatePromise = SearchTestUtils.promiseSearchNotification( + "settings-update-complete" + ); + await Services.search.addEngineWithDetails(kSearchEngineID1, { + method: "get", + template: kSearchEngineURL1, + }); + await Services.search.addEngineWithDetails(kSearchEngineID2, { + method: "get", + template: kSearchEngineURL2, + }); + await Services.search.addEngineWithDetails(kSearchEngineID3, { + method: "get", + template: kSearchEngineURL3, + extensionID: kExtensionID, + }); + + // Ensure that the initial remote settings update from default values is + // complete. The defaults do not include the special inclusions inserted below. + await updatePromise; + + for (let engineName of [ + kSearchEngineID1, + kSearchEngineID2, + kSearchEngineID3, + ]) { + Assert.ok( + await Services.search.getEngineByName(engineName), + `Engine ${engineName} should be present` + ); + } + + // Simulate an ignore list update. + await RemoteSettings("hijack-blocklists").emit("sync", { + data: { + current: [ + { + id: "load-paths", + schema: 1553857697843, + last_modified: 1553859483588, + matches: ["[other]addEngineWithDetails:searchignore@mozilla.com"], + }, + { + id: "submission-urls", + schema: 1553857697843, + last_modified: 1553859435500, + matches: ["ignore=true"], + }, + ], + }, + }); + + for (let engineName of [ + kSearchEngineID1, + kSearchEngineID2, + kSearchEngineID3, + ]) { + Assert.equal( + await Services.search.getEngineByName(engineName), + null, + `Engine ${engineName} should not be present` + ); + } +}); diff --git a/toolkit/components/search/tests/xpcshell/test_initialization.js b/toolkit/components/search/tests/xpcshell/test_initialization.js new file mode 100644 index 0000000000..da73a27824 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_initialization.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +let getStub; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + getStub = await SearchTestUtils.useTestEngines("simple-engines"); + + // We are testing receiving an empty configuration. + consoleAllowList.push("Received empty search configuration"); + consoleAllowList.push("_init: failure initializing search:"); +}); + +add_task(async function test_init_success() { + await Services.search.init(); + Assert.ok(Services.search.isInitialized); + + let scalars = Services.telemetry.getSnapshotForScalars("main", true).parent; + Assert.equal( + scalars["browser.searchinit.init_result_status_code"], + Cr.NS_OK, + "Should have recorded the engine settings as not corrupted" + ); + + await Services.search.init(); + + scalars = Services.telemetry.getSnapshotForScalars("main", true).parent; + Assert.ok(!scalars, "Should not have recorded the scalar a second time"); +}); + +add_task(async function test_initialization_failure() { + getStub.returns([]); + delete Services.search.wrappedJSObject._initStarted; + Services.search.wrappedJSObject._initObservers = PromiseUtils.defer(); + Services.search.wrappedJSObject._initRV = Cr.NS_OK; + + await Assert.rejects( + Services.search.init(), + ex => ex.result == Cr.NS_ERROR_UNEXPECTED, + "Should have failed to initialize" + ); + + let scalars = Services.telemetry.getSnapshotForScalars("main", true).parent; + Assert.equal( + scalars["browser.searchinit.init_result_status_code"], + Cr.NS_ERROR_UNEXPECTED, + "Should have recorded the unexpected error code" + ); + + await Assert.rejects( + Services.search.init(), + result => result == Cr.NS_ERROR_UNEXPECTED, + "Should have failed to initialize" + ); + + scalars = Services.telemetry.getSnapshotForScalars("main", true).parent; + Assert.ok(!scalars, "Should not have recorded the scalar a second time"); + + sinon.restore(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_initialization_with_region.js b/toolkit/components/search/tests/xpcshell/test_initialization_with_region.js new file mode 100644 index 0000000000..54a5adfc48 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_initialization_with_region.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const SEARCH_SERVICE_TOPIC = "browser-search-service"; +const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified"; + +const CONFIG = [ + { + webExtension: { + id: "engine@search.mozilla.org", + }, + orderHint: 30, + appliesTo: [ + { + included: { everywhere: true }, + excluded: { regions: ["FR"] }, + default: "yes", + defaultPrivate: "yes", + }, + ], + }, + { + webExtension: { + id: "engine-pref@search.mozilla.org", + }, + orderHint: 20, + appliesTo: [ + { + included: { regions: ["FR"] }, + default: "yes", + defaultPrivate: "yes", + }, + ], + }, +]; + +// Default engine with no region defined. +const DEFAULT = "Test search engine"; +// Default engine with region set to FR. +const FR_DEFAULT = "engine-pref"; + +function listenFor(name, key) { + let notifyObserved = false; + let obs = (subject, topic, data) => { + if (data == key) { + notifyObserved = true; + } + }; + Services.obs.addObserver(obs, name); + + return () => { + Services.obs.removeObserver(obs, name); + return notifyObserved; + }; +} + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.search.separatePrivateDefault", true); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.useTestEngines("data", null, CONFIG); + await AddonTestUtils.promiseStartupManager(); +}); + +// This tests what we expect is the normal startup route for a fresh profile - +// the search service initializes with no region details, then gets a region +// notified part way through / afterwards. +add_task(async function test_initialization_with_region() { + let reloadObserved = listenFor(SEARCH_SERVICE_TOPIC, "engines-reloaded"); + let initPromise; + + // Ensure the region lookup completes after init so the + // engines are reloaded + let srv = useHttpServer(); + srv.registerPathHandler("/fetch_region", async (req, res) => { + res.processAsync(); + await initPromise; + res.setStatusLine("1.1", 200, "OK"); + res.write(JSON.stringify({ country_code: "FR" })); + res.finish(); + }); + + Services.prefs.setCharPref( + "browser.region.network.url", + `http://localhost:${srv.identity.primaryPort}/fetch_region` + ); + + Region._setHomeRegion("", false); + Region.init(); + + initPromise = Services.search.init(); + await initPromise; + + let otherPromises = [ + // This test expects settings to be saved twice. + promiseAfterSettings().then(promiseAfterSettings), + SearchTestUtils.promiseSearchNotification( + "engine-default", + SEARCH_ENGINE_TOPIC + ), + ]; + + Assert.equal( + Services.search.defaultEngine.name, + DEFAULT, + "Test engine shouldn't be the default anymore" + ); + + await Promise.all(otherPromises); + + // Ensure that correct engine is being reported as the default. + Assert.equal( + Services.search.defaultEngine.name, + FR_DEFAULT, + "engine-pref should be the default in FR" + ); + Assert.equal( + (await Services.search.getDefaultPrivate()).name, + FR_DEFAULT, + "engine-pref should be the private default in FR" + ); + + Assert.ok(reloadObserved(), "Engines do reload with delayed region fetch"); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_list_json_locale.js b/toolkit/components/search/tests/xpcshell/test_list_json_locale.js new file mode 100644 index 0000000000..a02e07b9d2 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_list_json_locale.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Check default search engine is picked from list.json searchDefault */ + +"use strict"; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines(); + + Services.locale.availableLocales = [ + ...Services.locale.availableLocales, + "de", + "fr", + ]; + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + Region._setHomeRegion("US", false); +}); + +add_task(async function test_listJSONlocale() { + Services.locale.requestedLocales = ["de"]; + + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + + Assert.ok(Services.search.isInitialized, "search initialized"); + + let sortedEngines = await Services.search.getEngines(); + Assert.equal(sortedEngines.length, 1, "Should have only one engine"); + + Assert.equal( + Services.search.defaultEngine.name, + "Test search engine", + "Should have the correct default engine" + ); + Assert.equal( + Services.search.defaultPrivateEngine.name, + // 'de' only displays google, so we'll be using the same engine as the + // normal default. + "Test search engine", + "Should have the correct private default engine" + ); +}); + +// Check that switching locale switches search engines +add_task(async function test_listJSONlocaleSwitch() { + let defaultBranch = Services.prefs.getDefaultBranch( + SearchUtils.BROWSER_SEARCH_PREF + ); + defaultBranch.setCharPref("param.code", "good&id=unique"); + + await promiseSetLocale("fr"); + + Assert.ok(Services.search.isInitialized, "search initialized"); + + let sortedEngines = await Services.search.getEngines(); + Assert.deepEqual( + sortedEngines.map(e => e.name), + ["Test search engine", "engine-pref", "engine-resourceicon"], + "Should have the correct engine list" + ); + + Assert.equal( + Services.search.defaultEngine.name, + "Test search engine", + "Should have the correct default engine" + ); + Assert.equal( + Services.search.defaultPrivateEngine.name, + "engine-pref", + "Should have the correct private default engine" + ); +}); + +// Check that region overrides apply +add_task(async function test_listJSONRegionOverride() { + await promiseSetHomeRegion("RU"); + + Assert.ok(Services.search.isInitialized, "search initialized"); + + let sortedEngines = await Services.search.getEngines(); + Assert.deepEqual( + sortedEngines.map(e => e.name), + ["Test search engine", "engine-pref", "engine-chromeicon"], + "Should have the correct engine list" + ); + + Assert.equal( + Services.search.defaultEngine.name, + "Test search engine", + "Should have the correct default engine" + ); + Assert.equal( + Services.search.defaultPrivateEngine.name, + "engine-pref", + "Should have the correct private default engine" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_list_json_no_private_default.js b/toolkit/components/search/tests/xpcshell/test_list_json_no_private_default.js new file mode 100644 index 0000000000..5520ceeee2 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_list_json_no_private_default.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// TODO: Test fallback to normal default when no private set at all. + +/* Check default search engine is picked from list.json searchDefault */ + +"use strict"; + +// Check that current engine matches with US searchDefault from list.json +add_task(async function test_searchDefaultEngineUS() { + await SearchTestUtils.useTestEngines("data1"); + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + Services.prefs.setCharPref(SearchUtils.BROWSER_SEARCH_PREF + "region", "US"); + + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + + Assert.ok(Services.search.isInitialized, "search initialized"); + + Assert.equal( + Services.search.originalDefaultEngine.name, + "engine1", + "Should have the expected engine as original default" + ); + Assert.equal( + Services.search.defaultEngine.name, + "engine1", + "Should have the expected engine as default" + ); + Assert.equal( + Services.search.originalPrivateDefaultEngine.name, + "engine1", + "Should have the same engine for the original private default" + ); + Assert.equal( + Services.search.defaultPrivateEngine.name, + "engine1", + "Should have the same engine for the private default" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_list_json_searchdefault.js b/toolkit/components/search/tests/xpcshell/test_list_json_searchdefault.js new file mode 100644 index 0000000000..969201c8a0 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_list_json_searchdefault.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Check default search engine is picked from list.json searchDefault */ + +"use strict"; + +// Check that current engine matches with US searchDefault from list.json +add_task(async function test_searchDefaultEngineUS() { + await SearchTestUtils.useTestEngines(); + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + + Services.prefs.setCharPref(SearchUtils.BROWSER_SEARCH_PREF + "region", "US"); + + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + + Assert.ok(Services.search.isInitialized, "search initialized"); + + Assert.equal( + Services.search.defaultEngine.name, + "Test search engine", + "Should have the expected engine as default." + ); + Assert.equal( + Services.search.originalDefaultEngine.name, + "Test search engine", + "Should have the expected engine as the original" + ); + + // First with the pref off to check using the existing values. + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + false + ); + + Assert.equal( + Services.search.defaultPrivateEngine.name, + Services.search.defaultEngine.name, + "Should have the normal default engine when separate private browsing is off." + ); + Assert.equal( + Services.search.originalPrivateDefaultEngine.name, + Services.search.originalDefaultEngine.name, + "Should have the normal original engine when separate private browsing is off." + ); + + // Then with the pref on. + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + + Assert.equal( + Services.search.defaultPrivateEngine.name, + "engine-pref", + "Should have the private default engine when separate private browsing is on." + ); + Assert.equal( + Services.search.originalPrivateDefaultEngine.name, + "engine-pref", + "Should have the original private engine set correctly when separate private browsing is on." + ); + + Services.prefs.clearUserPref("browser.search.region"); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_list_json_searchorder.js b/toolkit/components/search/tests/xpcshell/test_list_json_searchorder.js new file mode 100644 index 0000000000..21e5b60f0f --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_list_json_searchorder.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Check default search engine is picked from list.json searchDefault */ + +"use strict"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + + await SearchTestUtils.useTestEngines(); + await Services.search.init(); +}); + +async function checkOrder(expectedOrder) { + const sortedEngines = await Services.search.getEngines(); + Assert.deepEqual( + sortedEngines.map(s => s.name), + expectedOrder, + "Should have the expected engine order" + ); +} + +add_task(async function test_searchOrderJSON_no_separate_private() { + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + false + ); + + await checkOrder([ + // Default engine + "Test search engine", + // Two engines listed in searchOrder. + "engine-resourceicon", + "engine-chromeicon", + // Rest of the engines in order. + "engine-pref", + "engine-rel-searchform-purpose", + "Test search engine (Reordered)", + ]); +}); + +add_task(async function test_searchOrderJSON_separate_private() { + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + + await checkOrder([ + // Default engine + "Test search engine", + // Default private engine + "engine-pref", + // Two engines listed in searchOrder. + "engine-resourceicon", + "engine-chromeicon", + // Rest of the engines in order. + "engine-rel-searchform-purpose", + "Test search engine (Reordered)", + ]); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_location_timeout_xhr.js b/toolkit/components/search/tests/xpcshell/test_location_timeout_xhr.js new file mode 100644 index 0000000000..d76d775dd5 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_location_timeout_xhr.js @@ -0,0 +1,90 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This is testing the long, last-resort XHR-based timeout for the location +// search. + +function startServer(continuePromise) { + let srv = new HttpServer(); + function lookupCountry(metadata, response) { + response.processAsync(); + // wait for our continuePromise to resolve before writing a valid + // response. + // This will be resolved after the timeout period, so we can check + // the behaviour in that case. + continuePromise.then(() => { + response.setStatusLine("1.1", 200, "OK"); + response.write('{"country_code" : "AU"}'); + response.finish(); + }); + } + srv.registerPathHandler("/lookup_country", lookupCountry); + srv.start(-1); + return srv; +} + +function verifyProbeSum(probe, sum) { + let histogram = Services.telemetry.getHistogramById(probe); + let snapshot = histogram.snapshot(); + equal(snapshot.sum, sum, probe); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_location_timeout_xhr() { + let resolveContinuePromise; + let continuePromise = new Promise(resolve => { + resolveContinuePromise = resolve; + }); + + let server = startServer(continuePromise); + let url = + "http://localhost:" + server.identity.primaryPort + "/lookup_country"; + Services.prefs.setCharPref("browser.search.geoip.url", url); + // The timeout for the timer. + Services.prefs.setIntPref("browser.search.geoip.timeout", 10); + let promiseXHRStarted = SearchTestUtils.promiseSearchNotification( + "geoip-lookup-xhr-starting" + ); + await Services.search.init(); + ok( + !Services.prefs.prefHasUserValue("browser.search.region"), + "should be no region pref" + ); + // should be no result recorded at all. + checkCountryResultTelemetry(null); + + // should not have SEARCH_SERVICE_COUNTRY_FETCH_TIME_MS recorded as our + // test server is still blocked on our promise. + verifyProbeSum("SEARCH_SERVICE_COUNTRY_FETCH_TIME_MS", 0); + + promiseXHRStarted.then(xhr => { + // Set the timeout on the xhr object to an extremely low value, so it + // should timeout immediately. + xhr.timeout = 10; + // wait for the xhr timeout to fire. + SearchTestUtils.promiseSearchNotification("geoip-lookup-xhr-complete").then( + () => { + // should have the XHR timeout recorded. + checkCountryResultTelemetry(TELEMETRY_RESULT_ENUM.TIMEOUT); + // still should not have a report of how long the response took as we + // only record that on success responses. + verifyProbeSum("SEARCH_SERVICE_COUNTRY_FETCH_TIME_MS", 0); + // and we still don't know the country code or region. + ok( + !Services.prefs.prefHasUserValue("browser.search.region"), + "should be no region pref" + ); + + // unblock the server even though nothing is listening. + resolveContinuePromise(); + + return new Promise(resolve => { + server.stop(resolve); + }); + } + ); + }); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_maybereloadengine_order.js b/toolkit/components/search/tests/xpcshell/test_maybereloadengine_order.js new file mode 100644 index 0000000000..67238088ee --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_maybereloadengine_order.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_CONFIG = [ + { + webExtension: { id: "plainengine@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + }, + { + webExtension: { id: "special-engine@search.mozilla.org" }, + appliesTo: [{ default: "yes", included: { regions: ["FR"] } }], + }, +]; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("test-extensions", null, TEST_CONFIG); + await AddonTestUtils.promiseStartupManager(); + + registerCleanupFunction(AddonTestUtils.promiseShutdownManager); +}); + +add_task(async function basic_multilocale_test() { + let resolver; + let initPromise = new Promise(resolve => (resolver = resolve)); + useCustomGeoServer("FR", initPromise); + + await Services.search.init(); + await Services.search.getAppProvidedEngines(); + resolver(); + await SearchTestUtils.promiseSearchNotification("engines-reloaded"); + + let engines = await Services.search.getAppProvidedEngines(); + + Assert.deepEqual( + engines.map(e => e._name), + ["Special", "Plain"], + "Special engine is default so should be first" + ); + + engines.forEach(engine => { + Assert.ok(!engine._metaData.order, "Order is not defined"); + }); + + Assert.equal( + Services.search.wrappedJSObject._settings.getAttribute("useSavedOrder"), + false, + "We should not set the engine order during maybeReloadEngines" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_migrateWebExtensionEngine.js b/toolkit/components/search/tests/xpcshell/test_migrateWebExtensionEngine.js new file mode 100644 index 0000000000..646995683f --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_migrateWebExtensionEngine.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kSearchEngineID = "addEngineWithDetails_test_engine"; +const kExtensionID = "test@example.com"; + +const kSearchEngineDetails = { + template: "http://example.com/?search={searchTerms}", + description: "Test Description", + iconURL: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + suggestURL: "http://example.com/?suggest={searchTerms}", + alias: "alias_foo", + extensionID: kExtensionID, +}; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_migrateLegacyEngine() { + Assert.ok(!Services.search.isInitialized); + + await Services.search.addEngineWithDetails( + kSearchEngineID, + kSearchEngineDetails + ); + + // Modify the loadpath so it looks like an legacy plugin loadpath + let engine = Services.search.getEngineByName(kSearchEngineID); + engine.wrappedJSObject._loadPath = `jar:[profile]/extensions/${kExtensionID}.xpi!/engine.xml`; + engine.wrappedJSObject._extensionID = null; + + // This should replace the existing engine + await Services.search.addEngineWithDetails( + kSearchEngineID, + kSearchEngineDetails + ); + + engine = Services.search.getEngineByName(kSearchEngineID); + Assert.equal( + engine.wrappedJSObject._loadPath, + "[other]addEngineWithDetails:" + kExtensionID + ); + Assert.equal(engine.wrappedJSObject._extensionID, kExtensionID); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_missing_engine.js b/toolkit/components/search/tests/xpcshell/test_missing_engine.js new file mode 100644 index 0000000000..e21e8ed191 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_missing_engine.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test is designed to check the search service keeps working if there's +// a built-in engine missing from the configuration. + +"use strict"; + +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +const SEARCH_SERVICE_TOPIC = "browser-search-service"; +const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified"; + +const GOOD_CONFIG = [ + { + webExtension: { + id: "engine@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + }, +]; + +const BAD_CONFIG = [ + ...GOOD_CONFIG, + { + webExtension: { + id: "engine-missing@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + }, +]; + +function listenFor(name, key) { + let notifyObserved = false; + let obs = (subject, topic, data) => { + if (data == key) { + notifyObserved = true; + } + }; + Services.obs.addObserver(obs, name); + + return () => { + Services.obs.removeObserver(obs, name); + return notifyObserved; + }; +} + +let configurationStub; + +add_task(async function setup() { + SearchTestUtils.useMockIdleService(); + await AddonTestUtils.promiseStartupManager(); + + // This test purposely attempts to load a missing engine. + consoleAllowList.push( + "Could not load engine engine-missing@search.mozilla.org" + ); +}); + +add_task(async function test_startup_with_missing() { + configurationStub = await SearchTestUtils.useTestEngines( + "data", + null, + BAD_CONFIG + ); + + const result = await Services.search.init(); + Assert.ok( + Components.isSuccessCode(result), + "Should have started the search service successfully." + ); + + const engines = await Services.search.getEngines(); + + Assert.deepEqual( + engines.map(e => e.name), + ["Test search engine"], + "Should have listed just the good engine" + ); +}); + +add_task(async function test_update_with_missing() { + let reloadObserved = SearchTestUtils.promiseSearchNotification( + "engines-reloaded" + ); + + await RemoteSettings(SearchUtils.SETTINGS_KEY).emit("sync", { + data: { + current: GOOD_CONFIG, + }, + }); + + SearchTestUtils.idleService._fireObservers("idle"); + + await reloadObserved; + + const engines = await Services.search.getEngines(); + + Assert.deepEqual( + engines.map(e => e.name), + ["Test search engine"], + "Should have just the good engine" + ); + + reloadObserved = SearchTestUtils.promiseSearchNotification( + "engines-reloaded" + ); + + await RemoteSettings(SearchUtils.SETTINGS_KEY).emit("sync", { + data: { + current: BAD_CONFIG, + }, + }); + + SearchTestUtils.idleService._fireObservers("idle"); + + await reloadObserved; + + Assert.deepEqual( + engines.map(e => e.name), + ["Test search engine"], + "Should still have just the good engine" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_multipleIcons.js b/toolkit/components/search/tests/xpcshell/test_multipleIcons.js new file mode 100644 index 0000000000..2b565958c8 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_multipleIcons.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests getIcons() and getIconURLBySize() on engine with multiple icons. + */ + +"use strict"; + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_multipleIcons() { + let [engine] = await addTestEngines([ + { name: "IconsTest", xmlFileName: "engineImages.xml" }, + ]); + + info("The default should be the 16x16 icon"); + Assert.ok(engine.iconURI.spec.includes("ico16")); + + Assert.ok(engine.getIconURLBySize(16, 16).includes("ico16")); + Assert.ok(engine.getIconURLBySize(32, 32).includes("ico32")); + Assert.ok(engine.getIconURLBySize(74, 74).includes("ico74")); + + info("Invalid dimensions should return null."); + Assert.equal(null, engine.getIconURLBySize(50, 50)); + + let allIcons = engine.getIcons(); + + info("Check that allIcons contains expected icon sizes"); + Assert.equal(allIcons.length, 3); + let expectedWidths = [16, 32, 74]; + Assert.ok( + allIcons.every(item => { + let width = item.width; + Assert.notEqual(expectedWidths.indexOf(width), -1); + Assert.equal(width, item.height); + + let icon = item.url.split(",").pop(); + Assert.equal(icon, "ico" + width); + + return true; + }) + ); +}); + +add_task(async function test_icon_not_in_file() { + let engineUrl = gDataUrl + "engine-fr.xml"; + let engine = await Services.search.addOpenSearchEngine( + engineUrl, + "data:image/x-icon;base64,ico16" + ); + + // Even though the icon wasn't specified inside the XML file, it should be + // available both in the iconURI attribute and with getIconURLBySize. + Assert.ok(engine.iconURI.spec.includes("ico16")); + Assert.ok(engine.getIconURLBySize(16, 16).includes("ico16")); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_nodb_pluschanges.js b/toolkit/components/search/tests/xpcshell/test_nodb_pluschanges.js new file mode 100644 index 0000000000..233f2b1eff --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_nodb_pluschanges.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * test_nodb: Start search service without existing settings file. + * + * Ensure that : + * - nothing explodes; + * - if we change the order, search.json.mozlz4 is updated; + * - this search.json.mozlz4 can be parsed; + * - the order stored in search.json.mozlz4 is consistent. + * + * Notes: + * - we install the search engines of test "test_downloadAndAddEngines.js" + * to ensure that this test is independent from locale, commercial agreements + * and configuration of Firefox. + */ + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_nodb_pluschanges() { + let [engine1, engine2] = await addTestEngines([ + { name: "Test search engine", xmlFileName: "engine.xml" }, + { name: "A second test engine", xmlFileName: "engine2.xml" }, + ]); + await promiseAfterSettings(); + + let search = Services.search; + + await search.moveEngine(engine1, 0); + await search.moveEngine(engine2, 1); + + // This is needed to avoid some reentrency issues in nsSearchService. + info("Next step is forcing flush"); + await new Promise(resolve => executeSoon(resolve)); + + info("Forcing flush"); + let promiseCommit = promiseAfterSettings(); + search.QueryInterface(Ci.nsIObserver).observe(null, "quit-application", ""); + await promiseCommit; + info("Commit complete"); + + // Check that the entries are placed as specified correctly + let metadata = await promiseEngineMetadata(); + Assert.equal(metadata["Test search engine"].order, 1); + Assert.equal(metadata["A second test engine"].order, 2); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_notifications.js b/toolkit/components/search/tests/xpcshell/test_notifications.js new file mode 100644 index 0000000000..fae2dee1a5 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_notifications.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let engine; +let originalDefaultEngine; + +SearchTestUtils.initXPCShellAddonManager(this); + +/** + * A simple observer to ensure we get only the expected notifications. + */ +class SearchObserver { + constructor(expectedNotifications, returnAddedEngine = false) { + this.observer = this.observer.bind(this); + this.deferred = PromiseUtils.defer(); + this.expectedNotifications = expectedNotifications; + this.returnAddedEngine = returnAddedEngine; + + Services.obs.addObserver(this.observer, SearchUtils.TOPIC_ENGINE_MODIFIED); + } + + get promise() { + return this.deferred.promise; + } + + observer(subject, topic, data) { + Assert.greater( + this.expectedNotifications.length, + 0, + "Should be expecting a notification" + ); + Assert.equal( + data, + this.expectedNotifications[0], + "Should have received the next expected notification" + ); + + if (this.returnAddedEngine && data == SearchUtils.MODIFIED_TYPE.ADDED) { + this.addedEngine = subject.QueryInterface(Ci.nsISearchEngine); + } + + this.expectedNotifications.shift(); + + if (!this.expectedNotifications.length) { + this.deferred.resolve(this.addedEngine); + Services.obs.removeObserver( + this.observer, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); + } + } +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + useHttpServer(); + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + + originalDefaultEngine = await Services.search.getDefault(); +}); + +add_task(async function test_addingEngine_opensearch() { + const addEngineObserver = new SearchObserver( + [ + // engine-loaded + // Engine was loaded. + SearchUtils.MODIFIED_TYPE.LOADED, + // engine-added + // Engine was added to the store by the search service. + SearchUtils.MODIFIED_TYPE.ADDED, + ], + true + ); + + await Services.search.addOpenSearchEngine(gDataUrl + "engine.xml", null); + + engine = await addEngineObserver.promise; + + let retrievedEngine = Services.search.getEngineByName("Test search engine"); + Assert.equal(engine, retrievedEngine); +}); + +add_task(async function test_addingEngine_webExtension() { + const addEngineObserver = new SearchObserver( + [ + // engine-added + // Engine was added to the store by the search service. + SearchUtils.MODIFIED_TYPE.ADDED, + ], + true + ); + + let extension = await SearchTestUtils.installSearchExtension({ + name: "Example Engine", + }); + await extension.awaitStartup(); + + let webExtensionEngine = await addEngineObserver.promise; + + let retrievedEngine = Services.search.getEngineByName("Example Engine"); + Assert.equal(webExtensionEngine, retrievedEngine); + await extension.unload(); +}); + +async function defaultNotificationTest( + setPrivateDefault, + expectNotificationForPrivate +) { + const defaultObserver = new SearchObserver([ + expectNotificationForPrivate + ? SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE + : SearchUtils.MODIFIED_TYPE.DEFAULT, + ]); + + Services.search[ + setPrivateDefault ? "defaultPrivateEngine" : "defaultEngine" + ] = engine; + await defaultObserver.promise; +} + +add_task(async function test_defaultEngine_notifications() { + await defaultNotificationTest(false, false); +}); + +add_task(async function test_defaultPrivateEngine_notifications() { + await defaultNotificationTest(true, true); +}); + +add_task( + async function test_defaultPrivateEngine_notifications_when_not_enabled() { + await Services.search.setDefault(originalDefaultEngine); + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + false + ); + + await defaultNotificationTest(true, true); + } +); + +add_task(async function test_removeEngine() { + const removedObserver = new SearchObserver([ + SearchUtils.MODIFIED_TYPE.REMOVED, + ]); + + await Services.search.removeEngine(engine); + + await removedObserver; +}); diff --git a/toolkit/components/search/tests/xpcshell/test_opensearch.js b/toolkit/components/search/tests/xpcshell/test_opensearch.js new file mode 100644 index 0000000000..1145eda43d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_opensearch.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests that OpenSearch engines are installed and set up correctly. + * + * Note: simple.xml, post.xml, suggestion.xml and suggestion-alternate.xml + * all use different namespaces to reflect the possibitities that may be + * installed. + * mozilla-ns.xml uses the mozilla namespace. + */ + +"use strict"; + +const tests = [ + { + file: "simple.xml", + name: "simple", + description: "A small test engine", + searchForm: "https://example.com/", + searchUrl: "https://example.com/search?q=foo", + }, + { + file: "post.xml", + name: "Post", + description: "", + // The POST method is not supported for `rel="searchform"` so we fallback + // to the `SearchForm` url. + searchForm: "http://engine-rel-searchform-post.xml/?search", + searchUrl: "https://example.com/post", + searchPostData: "searchterms=foo", + }, + { + file: "suggestion.xml", + name: "suggestion", + description: "A small engine with suggestions", + queryCharset: "windows-1252", + searchForm: "http://engine-rel-searchform.xml/?search", + searchUrl: "https://example.com/search?q=foo", + suggestUrl: "https://example.com/suggest?suggestion=foo", + }, + { + file: "suggestion-alternate.xml", + name: "suggestion-alternate", + description: "A small engine with suggestions", + searchForm: "https://example.com/", + searchUrl: "https://example.com/search?q=foo", + suggestUrl: "https://example.com/suggest?suggestion=foo", + }, + { + file: "mozilla-ns.xml", + name: "mozilla-ns", + description: "An engine using mozilla namespace", + searchForm: "https://example.com/", + // mozilla-ns.xml also specifies a MozParam. However, they are only + // valid for app-provided engines, and hence the param should not show + // here. + searchUrl: "https://example.com/search?q=foo", + }, +]; + +add_task(async function setup() { + useHttpServer("opensearch"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +for (const test of tests) { + add_task(async () => { + info(`Testing ${test.file}`); + let promiseEngineAdded = SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.ADDED, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); + let engine = await Services.search.addOpenSearchEngine( + gDataUrl + test.file, + null + ); + await promiseEngineAdded; + Assert.ok(engine, "Should have installed the engine."); + + Assert.equal(engine.name, test.name, "Should have the correct name"); + Assert.equal( + engine.description, + test.description, + "Should have a description" + ); + + Assert.equal( + engine.wrappedJSObject._loadPath, + `[http]localhost/${test.file}` + ); + + Assert.equal( + engine.queryCharset, + test.queryCharset ?? SearchUtils.DEFAULT_QUERY_CHARSET, + "Should have the expected query charset" + ); + + let submission = engine.getSubmission("foo"); + Assert.equal( + submission.uri.spec, + test.searchUrl, + "Should have the correct search url" + ); + + if (test.searchPostData) { + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(submission.postData); + let data = sis.read(submission.postData.available()); + Assert.equal( + decodeURIComponent(data), + test.searchPostData, + "Should have received the correct POST data" + ); + } else { + Assert.equal( + submission.postData, + null, + "Should have not received any POST data" + ); + } + + Assert.equal( + engine.searchForm, + test.searchForm, + "Should have the correct search form url" + ); + + submission = engine.getSubmission("foo", SearchUtils.URL_TYPE.SUGGEST_JSON); + if (test.suggestUrl) { + Assert.equal( + submission.uri.spec, + test.suggestUrl, + "Should have the correct suggest url" + ); + } else { + Assert.equal(submission, null, "Should not have a suggestion url"); + } + }); +} diff --git a/toolkit/components/search/tests/xpcshell/test_opensearch_icon.js b/toolkit/components/search/tests/xpcshell/test_opensearch_icon.js new file mode 100644 index 0000000000..0b836f9232 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_opensearch_icon.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + let server = useHttpServer(); + server.registerContentType("sjs", "sjs"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +const tests = [ + { + name: "Big Icon", + image: "bigIcon.ico", + expected: "data:image/png;base64,", + }, + { + name: "Remote Icon", + image: "remoteIcon.ico", + expected: "data:image/x-icon;base64,", + }, + { + name: "SVG Icon", + image: "svgIcon.svg", + expected: "data:image/svg+xml;base64,", + }, +]; + +for (const test of tests) { + add_task(async function() { + info(`Testing ${test.name}`); + + let promiseEngineAdded = SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.ADDED, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); + let promiseEngineChanged = SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.CHANGED, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); + const engineData = { + baseURL: gDataUrl, + image: test.image, + name: test.name, + method: "GET", + }; + // The easiest way to test adding the icon is via a generated xml, otherwise + // we have to somehow insert the address of the server into it. + addTestEngines([ + { + name: test.name, + xmlFileName: "engineMaker.sjs?" + JSON.stringify(engineData), + }, + ]); + let engine = await promiseEngineAdded; + await promiseEngineChanged; + + Assert.ok(engine.iconURI, "the engine has an icon"); + Assert.ok( + engine.iconURI.spec.startsWith(test.expected), + "the icon is saved as an x-icon data url" + ); + }); +} diff --git a/toolkit/components/search/tests/xpcshell/test_opensearch_icons_invalid.js b/toolkit/components/search/tests/xpcshell/test_opensearch_icons_invalid.js new file mode 100644 index 0000000000..dd1486e0d0 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_opensearch_icons_invalid.js @@ -0,0 +1,20 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Test that an installed engine can't use a resource URL for an icon */ + +"use strict"; + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_installedresourceicon() { + let [engine1, engine2] = await addTestEngines([ + { name: "engine-resourceicon", xmlFileName: "engine-resourceicon.xml" }, + { name: "engine-chromeicon", xmlFileName: "engine-chromeicon.xml" }, + ]); + Assert.equal(null, engine1.iconURI); + Assert.equal(null, engine2.iconURI); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_opensearch_install_errors.js b/toolkit/components/search/tests/xpcshell/test_opensearch_install_errors.js new file mode 100644 index 0000000000..3168b7a05d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_opensearch_install_errors.js @@ -0,0 +1,66 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test that various install failures are handled correctly. + */ + +add_task(async function setup() { + useHttpServer("opensearch"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + + // This test purposely attempts to load an invalid engine. + consoleAllowList.push("_onLoad: Failed to init engine!"); +}); + +add_task(async function test_invalid_path_fails() { + await Assert.rejects( + Services.search.addOpenSearchEngine("http://invalid/data/engine.xml", null), + error => { + Assert.equal( + error.result, + Ci.nsISearchService.ERROR_DOWNLOAD_FAILURE, + "Should have returned download failure." + ); + return true; + }, + "Should fail to install an engine with an invalid path." + ); +}); + +add_task(async function test_install_duplicate_fails() { + let engine = await Services.search.addOpenSearchEngine( + gDataUrl + "simple.xml", + null + ); + Assert.equal(engine.name, "simple", "Should have installed the engine."); + + await Assert.rejects( + Services.search.addOpenSearchEngine(gDataUrl + "simple.xml", null), + error => { + Assert.equal( + error.result, + Ci.nsISearchService.ERROR_DUPLICATE_ENGINE, + "Should have returned duplicate failure." + ); + return true; + }, + "Should fail to install a duplicate engine." + ); +}); + +add_task(async function test_invalid_engine_from_dir() { + await Assert.rejects( + Services.search.addOpenSearchEngine(gDataUrl + "invalid.xml", null), + error => { + Assert.equal( + error.result, + Ci.nsISearchService.ERROR_ENGINE_CORRUPTED, + "Should have returned corruption failure." + ); + return true; + }, + "Should fail to install an invalid engine." + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_opensearch_update.js b/toolkit/components/search/tests/xpcshell/test_opensearch_update.js new file mode 100644 index 0000000000..46883f0d7f --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_opensearch_update.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Test that user-set metadata isn't lost on engine update */ + +"use strict"; + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function test_engineUpdate() { + const KEYWORD = "keyword"; + const FILENAME = "engine.xml"; + const TOPIC = "browser-search-engine-modified"; + const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; + + let [engine] = await addTestEngines([ + { name: "Test search engine", xmlFileName: FILENAME }, + ]); + + engine.alias = KEYWORD; + await Services.search.moveEngine(engine, 0); + // can't have an accurate updateURL in the file since we can't know the test + // server origin, so manually set it + engine.wrappedJSObject._updateURL = gDataUrl + FILENAME; + + await new Promise(resolve => { + Services.obs.addObserver(function obs(subject, topic, data) { + if (data == "engine-loaded") { + let loadedEngine = subject.QueryInterface(Ci.nsISearchEngine); + let rawEngine = loadedEngine.wrappedJSObject; + equal(loadedEngine.alias, KEYWORD, "Keyword not cleared by update"); + equal(rawEngine.getAttr("order"), 1, "Order not cleared by update"); + Services.obs.removeObserver(obs, TOPIC); + resolve(); + } + }, TOPIC); + + // set last update to 8 days ago, since the default interval is 7, then + // trigger an update + engine.wrappedJSObject.setAttr( + "updateexpir", + Date.now() - ONE_DAY_IN_MS * 8 + ); + Services.search.QueryInterface(Ci.nsITimerCallback).notify(null); + }); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_originalDefaultEngine.js b/toolkit/components/search/tests/xpcshell/test_originalDefaultEngine.js new file mode 100644 index 0000000000..39d0d9300e --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_originalDefaultEngine.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test that originalDefaultEngine property is set and switches correctly. + */ + +"use strict"; + +add_task(async function setup() { + Region._setHomeRegion("an", false); + await AddonTestUtils.promiseStartupManager(); + await SearchTestUtils.useTestEngines("test-extensions"); +}); + +function promiseDefaultNotification() { + return SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.DEFAULT, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); +} + +add_task(async function test_originalDefaultEngine() { + await Promise.all([Services.search.init(), promiseAfterSettings()]); + Assert.equal( + Services.search.originalDefaultEngine.name, + "Multilocale AN", + "Should have returned the correct original default engine" + ); +}); + +add_task(async function test_changeRegion() { + // Now change the region, and check we get the correct default according to + // the config file. + + // Note: the test could be done with changing regions or locales. The important + // part is that the default engine is changing across the switch, and that + // the engine is not the first one in the new sorted engines list. + await promiseSetHomeRegion("tr"); + + Assert.equal( + Services.search.originalDefaultEngine.name, + // Very important this default is not the first one in the list (which is + // the next fallback if the config one can't be found). + "Special", + "Should have returned the correct engine for the new locale" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_override_allowlist.js b/toolkit/components/search/tests/xpcshell/test_override_allowlist.js new file mode 100644 index 0000000000..5e262bef71 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_override_allowlist.js @@ -0,0 +1,393 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kBaseURL = "https://example.com/"; +const kSearchEngineURL = `${kBaseURL}?q={searchTerms}&foo=myparams`; +const kOverriddenEngineName = "Simple Engine"; + +SearchTestUtils.initXPCShellAddonManager(this); + +const whitelist = [ + { + thirdPartyId: "test@thirdparty.example.com", + overridesId: "simple@search.mozilla.org", + urls: [], + }, +]; + +const tests = [ + { + title: "test_not_changing_anything", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: "MozParamsTest2", + keyword: "MozSearch", + search_url: kSearchEngineURL, + }, + expected: { + switchToDefaultAllowed: false, + canInstallEngine: true, + overridesEngine: false, + }, + }, + { + title: "test_changing_default_engine", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kSearchEngineURL, + }, + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: false, + }, + }, + { + title: "test_changing_default_engine", + startupReason: "ADDON_ENABLE", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kSearchEngineURL, + }, + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: false, + }, + }, + { + title: "test_overriding_default_engine", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kSearchEngineURL, + }, + whitelistUrls: [ + { + search_url: kSearchEngineURL, + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: true, + searchUrl: kSearchEngineURL, + }, + }, + { + title: "test_overriding_default_engine_enable", + startupReason: "ADDON_ENABLE", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kSearchEngineURL, + }, + whitelistUrls: [ + { + search_url: kSearchEngineURL, + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: true, + searchUrl: kSearchEngineURL, + }, + }, + { + title: "test_overriding_default_engine_different_url", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kSearchEngineURL + "a", + }, + whitelistUrls: [ + { + search_url: kSearchEngineURL, + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: false, + }, + }, + { + title: "test_overriding_default_engine_get_params", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kBaseURL, + search_url_get_params: "q={searchTerms}&enc=UTF-8", + }, + whitelistUrls: [ + { + search_url: kBaseURL, + search_url_get_params: "q={searchTerms}&enc=UTF-8", + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: true, + searchUrl: `${kBaseURL}?q={searchTerms}&enc=UTF-8`, + }, + }, + { + title: "test_overriding_default_engine_different_get_params", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kBaseURL, + search_url_get_params: "q={searchTerms}&enc=UTF-8a", + }, + whitelistUrls: [ + { + search_url: kBaseURL, + search_url_get_params: "q={searchTerms}&enc=UTF-8", + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: false, + }, + }, + { + title: "test_overriding_default_engine_post_params", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kBaseURL, + search_url_post_params: "q={searchTerms}&enc=UTF-8", + }, + whitelistUrls: [ + { + search_url: kBaseURL, + search_url_post_params: "q={searchTerms}&enc=UTF-8", + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: true, + searchUrl: `${kBaseURL}`, + postData: "q={searchTerms}&enc=UTF-8", + }, + }, + { + title: "test_overriding_default_engine_different_post_params", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kBaseURL, + search_url_post_params: "q={searchTerms}&enc=UTF-8a", + }, + whitelistUrls: [ + { + search_url: kBaseURL, + search_url_post_params: "q={searchTerms}&enc=UTF-8", + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: false, + }, + }, + { + title: "test_overriding_default_engine_search_form", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kBaseURL, + search_form: "https://example.com/form", + }, + whitelistUrls: [ + { + search_url: kBaseURL, + search_form: "https://example.com/form", + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: true, + searchUrl: `${kBaseURL}`, + searchForm: "https://example.com/form", + }, + }, + { + title: "test_overriding_default_engine_different_search_form", + startupReason: "ADDON_INSTALL", + search_provider: { + is_default: true, + name: kOverriddenEngineName, + keyword: "MozSearch", + search_url: kBaseURL, + search_form: "https://example.com/forma", + }, + whitelistUrls: [ + { + search_url: kBaseURL, + search_form: "https://example.com/form", + }, + ], + expected: { + switchToDefaultAllowed: true, + canInstallEngine: false, + overridesEngine: false, + }, + }, +]; + +let baseExtension; +let remoteSettingsStub; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("simple-engines"); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + + baseExtension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test@thirdparty.example.com", + }, + }, + }, + useAddonManager: "permanent", + }); + await baseExtension.startup(); + + const settings = await RemoteSettings(SearchUtils.SETTINGS_ALLOWLIST_KEY); + remoteSettingsStub = sinon.stub(settings, "get").returns([]); + + registerCleanupFunction(async () => { + await baseExtension.unload(); + }); +}); + +for (const test of tests) { + add_task(async () => { + info(test.title); + + let extension = { + ...baseExtension, + startupReason: test.startupReason, + manifest: { + chrome_settings_overrides: { + search_provider: test.search_provider, + }, + }, + }; + + if (test.expected.overridesEngine) { + remoteSettingsStub.returns([ + { ...whitelist[0], urls: test.whitelistUrls }, + ]); + } + + let result = await Services.search.maybeSetAndOverrideDefault(extension); + Assert.equal( + result.canChangeToAppProvided, + test.expected.switchToDefaultAllowed, + "Should have returned the correct value for allowing switch to default or not." + ); + Assert.equal( + result.canInstallEngine, + test.expected.canInstallEngine, + "Should have returned the correct value for allowing to install the engine or not." + ); + + let engine = await Services.search.getEngineByName(kOverriddenEngineName); + Assert.equal( + !!engine.wrappedJSObject.getAttr("overriddenBy"), + test.expected.overridesEngine, + "Should have correctly overridden or not." + ); + + Assert.equal( + engine.telemetryId, + "simple" + (test.expected.overridesEngine ? "-addon" : ""), + "Should set the correct telemetry Id" + ); + + if (test.expected.overridesEngine) { + let submission = engine.getSubmission("{searchTerms}"); + Assert.equal( + decodeURI(submission.uri.spec), + test.expected.searchUrl, + "Should have set the correct url on an overriden engine" + ); + + if (test.expected.search_form) { + Assert.equal( + engine.wrappedJSObject._searchForm, + test.expected.searchForm, + "Should have overridden the search form." + ); + } + + if (test.expected.postData) { + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(submission.postData); + let data = sis.read(submission.postData.available()); + Assert.equal( + decodeURIComponent(data), + test.expected.postData, + "Should have overridden the postData" + ); + } + + // As we're not testing the WebExtension manager as well, + // set this engine as default so we can check the telemetry data. + let oldDefaultEngine = Services.search.defaultEngine; + Services.search.defaultEngine = engine; + + let engineInfo = await Services.search.getDefaultEngineInfo(); + Assert.deepEqual( + engineInfo, + { + defaultSearchEngine: "simple-addon", + defaultSearchEngineData: { + loadPath: "[other]addEngineWithDetails:simple@search.mozilla.org", + name: "Simple Engine", + origin: "default", + submissionURL: test.expected.searchUrl.replace("{searchTerms}", ""), + }, + }, + "Should return the extended identifier and alternate submission url to telemetry" + ); + Services.search.defaultEngine = oldDefaultEngine; + + engine.wrappedJSObject.removeExtensionOverride(); + } + }); +} diff --git a/toolkit/components/search/tests/xpcshell/test_parseSubmissionURL.js b/toolkit/components/search/tests/xpcshell/test_parseSubmissionURL.js new file mode 100644 index 0000000000..df7ff327fb --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_parseSubmissionURL.js @@ -0,0 +1,173 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests getAlternateDomains API. + */ + +"use strict"; + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_parseSubmissionURL() { + // Hide the default engines to prevent them from being used in the search. + for (let engine of await Services.search.getEngines()) { + await Services.search.removeEngine(engine); + } + + let [engine1, engine2, engine3, engine4] = await addTestEngines([ + { name: "Test search engine", xmlFileName: "engine.xml" }, + { name: "Test search engine (fr)", xmlFileName: "engine-fr.xml" }, + { + name: "bacon_addParam", + details: { + alias: "bacon_addParam", + encoding: "windows-1252", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.test/find", + searchGetParams: "q={searchTerms}", + }, + }, + { + name: "idn_addParam", + details: { + alias: "idn_addParam", + description: "Search IDN", + method: "GET", + template: "http://www.xn--bcher-kva.ch/search", + searchGetParams: "q={searchTerms}", + }, + }, + // The following engines cannot identify the search parameter. + { name: "A second test engine", xmlFileName: "engine2.xml" }, + { + name: "bacon", + details: { + alias: "bacon", + description: "Search Bacon", + method: "GET", + template: "http://www.bacon.moz/search?q={searchTerms}", + }, + }, + ]); + + // Test the first engine, whose URLs use UTF-8 encoding. + let url = "http://www.google.com/search?foo=bar&q=caff%C3%A8"; + let result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine1); + Assert.equal(result.terms, "caff\u00E8"); + Assert.ok(url.slice(result.termsOffset).startsWith("caff%C3%A8")); + Assert.equal(result.termsLength, "caff%C3%A8".length); + + // The second engine uses a locale-specific domain that is an alternate domain + // of the first one, but the second engine should get priority when matching. + // The URL used with this engine uses ISO-8859-1 encoding instead. + url = "http://www.google.fr/search?q=caff%E8"; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine2); + Assert.equal(result.terms, "caff\u00E8"); + Assert.ok(url.slice(result.termsOffset).startsWith("caff%E8")); + Assert.equal(result.termsLength, "caff%E8".length); + + // Test a domain that is an alternate domain of those defined. In this case, + // the first matching engine from the ordered list should be returned. + url = "http://www.google.co.uk/search?q=caff%C3%A8"; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine1); + Assert.equal(result.terms, "caff\u00E8"); + Assert.ok(url.slice(result.termsOffset).startsWith("caff%C3%A8")); + Assert.equal(result.termsLength, "caff%C3%A8".length); + + // We support parsing URLs from a dynamically added engine. + url = "http://www.bacon.test/find?q=caff%E8"; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine3); + Assert.equal(result.terms, "caff\u00E8"); + Assert.ok(url.slice(result.termsOffset).startsWith("caff%E8")); + Assert.equal(result.termsLength, "caff%E8".length); + + // Test URLs with unescaped unicode characters. + url = "http://www.google.com/search?q=foo+b\u00E4r"; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine1); + Assert.equal(result.terms, "foo b\u00E4r"); + Assert.ok(url.slice(result.termsOffset).startsWith("foo+b\u00E4r")); + Assert.equal(result.termsLength, "foo+b\u00E4r".length); + + // Test search engines with unescaped IDNs. + url = "http://www.b\u00FCcher.ch/search?q=foo+bar"; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine4); + Assert.equal(result.terms, "foo bar"); + Assert.ok(url.slice(result.termsOffset).startsWith("foo+bar")); + Assert.equal(result.termsLength, "foo+bar".length); + + // Test search engines with escaped IDNs. + url = "http://www.xn--bcher-kva.ch/search?q=foo+bar"; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine4); + Assert.equal(result.terms, "foo bar"); + Assert.ok(url.slice(result.termsOffset).startsWith("foo+bar")); + Assert.equal(result.termsLength, "foo+bar".length); + + // Parsing of parameters from an engine template URL is not supported. + Assert.equal( + Services.search.parseSubmissionURL("http://www.bacon.moz/search?q=").engine, + null + ); + Assert.equal( + Services.search.parseSubmissionURL("https://duckduckgo.com?q=test").engine, + null + ); + Assert.equal( + Services.search.parseSubmissionURL("https://duckduckgo.com/?q=test").engine, + null + ); + + // HTTP and HTTPS schemes are interchangeable. + url = "https://www.google.com/search?q=caff%C3%A8"; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine1); + Assert.equal(result.terms, "caff\u00E8"); + Assert.ok(url.slice(result.termsOffset).startsWith("caff%C3%A8")); + + // Decoding search terms with multiple spaces should work. + result = Services.search.parseSubmissionURL( + "http://www.google.com/search?q=+with++spaces+" + ); + Assert.equal(result.engine, engine1); + Assert.equal(result.terms, " with spaces "); + + // An empty query parameter should work the same. + url = "http://www.google.com/search?q="; + result = Services.search.parseSubmissionURL(url); + Assert.equal(result.engine, engine1); + Assert.equal(result.terms, ""); + Assert.equal(result.termsOffset, url.length); + + // There should be no match when the path is different. + result = Services.search.parseSubmissionURL( + "http://www.google.com/search/?q=test" + ); + Assert.equal(result.engine, null); + Assert.equal(result.terms, ""); + Assert.equal(result.termsOffset, -1); + + // There should be no match when the argument is different. + result = Services.search.parseSubmissionURL( + "http://www.google.com/search?q2=test" + ); + Assert.equal(result.engine, null); + Assert.equal(result.terms, ""); + Assert.equal(result.termsOffset, -1); + + // There should be no match for URIs that are not HTTP or HTTPS. + result = Services.search.parseSubmissionURL("file://localhost/search?q=test"); + Assert.equal(result.engine, null); + Assert.equal(result.terms, ""); + Assert.equal(result.termsOffset, -1); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_pref.js b/toolkit/components/search/tests/xpcshell/test_pref.js new file mode 100644 index 0000000000..2445ea2100 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_pref.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Test that MozParam condition="pref" values used in search URLs are from the + * default branch, and that their special characters are URL encoded. */ + +"use strict"; + +const defaultBranch = Services.prefs.getDefaultBranch( + SearchUtils.BROWSER_SEARCH_PREF +); +const baseURL = "https://www.google.com/search?q=foo"; + +add_task(async function setup() { + // The test engines used in this test need to be recognized as 'default' + // engines, or their MozParams will be ignored. + await SearchTestUtils.useTestEngines(); +}); + +add_task(async function test_pref_initial_value() { + defaultBranch.setCharPref("param.code", "good&id=unique"); + Services.prefs.setCharPref( + SearchUtils.BROWSER_SEARCH_PREF + "param.code", + "bad" + ); + + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + + const engine = Services.search.getEngineByName("engine-pref"); + const base = baseURL + "&code="; + Assert.equal( + engine.getSubmission("foo").uri.spec, + base + "good%26id%3Dunique", + "Should have got the submission URL with the correct code" + ); + + // Now clear the user-set preference. Having a user set preference means + // we don't get updates from the pref service of changes on the default + // branch. Normally, this won't be an issue, since we don't expect users + // to be playing with these prefs, and worst-case, they'll just get the + // actual change on restart. + Services.prefs.clearUserPref(SearchUtils.BROWSER_SEARCH_PREF + "param.code"); +}); + +add_task(async function test_pref_updated() { + // Update the pref without re-init nor restart. + defaultBranch.setCharPref("param.code", "supergood&id=unique123456"); + + const engine = Services.search.getEngineByName("engine-pref"); + const base = baseURL + "&code="; + Assert.equal( + engine.getSubmission("foo").uri.spec, + base + "supergood%26id%3Dunique123456", + "Should have got the submission URL with the updated code" + ); +}); + +add_task(async function test_pref_cleared() { + // Update the pref without re-init nor restart. + // Note you can't delete a preference from the default branch. + defaultBranch.setCharPref("param.code", ""); + + let engine = Services.search.getEngineByName("engine-pref"); + Assert.equal( + engine.getSubmission("foo").uri.spec, + baseURL, + "Should have just the base URL after the pref was cleared" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_purpose.js b/toolkit/components/search/tests/xpcshell/test_purpose.js new file mode 100644 index 0000000000..7320276e4f --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_purpose.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test that a search purpose can be specified and that query parameters for + * that purpose are included in the search URL. + */ + +"use strict"; + +add_task(async function setup() { + // The test engines used in this test need to be recognized as 'default' + // engines, or their MozParams used to set the purpose will be ignored. + await SearchTestUtils.useTestEngines(); + + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function test_purpose() { + let engine = Services.search.getEngineByName("Test search engine"); + + function check_submission(aValue, aSearchTerm, aType, aPurpose) { + let submissionURL = engine.getSubmission(aSearchTerm, aType, aPurpose).uri + .spec; + let searchParams = new URLSearchParams(submissionURL.split("?")[1]); + if (aValue) { + Assert.equal(searchParams.get("channel"), aValue); + } else { + Assert.ok(!searchParams.has("channel")); + } + Assert.equal(searchParams.get("q"), aSearchTerm); + } + + check_submission("", "foo"); + check_submission("", "foo", null); + check_submission("", "foo", "text/html"); + check_submission("rcs", "foo", null, "contextmenu"); + check_submission("rcs", "foo", "text/html", "contextmenu"); + check_submission("fflb", "foo", null, "keyword"); + check_submission("fflb", "foo", "text/html", "keyword"); + check_submission("", "foo", "text/html", "invalid"); + + // Tests for a purpose on the search form (ie. empty query). + engine = Services.search.getEngineByName("engine-rel-searchform-purpose"); + + // See bug 1485508 + Assert.ok(!engine.searchForm.includes("?&")); + + // verify that the 'system' purpose falls back to the 'searchbar' purpose. + check_submission("sb", "foo", "text/html", "system"); + check_submission("sb", "foo", "text/html", "searchbar"); +}); + +add_task(async function test_purpose() { + let engine = Services.search.getEngineByName( + "Test search engine (Reordered)" + ); + + function check_submission(aValue, aSearchTerm, aType, aPurpose) { + let submissionURL = engine.getSubmission(aSearchTerm, aType, aPurpose).uri + .spec; + let searchParams = new URLSearchParams(submissionURL.split("?")[1]); + if (aValue) { + Assert.equal(searchParams.get("channel"), aValue); + } else { + Assert.ok(!searchParams.has("channel")); + } + Assert.equal(searchParams.get("q"), aSearchTerm); + } + + check_submission("", "foo"); + check_submission("", "foo", null); + check_submission("", "foo", "text/html"); + check_submission("rcs", "foo", null, "contextmenu"); + check_submission("rcs", "foo", "text/html", "contextmenu"); + check_submission("fflb", "foo", null, "keyword"); + check_submission("fflb", "foo", "text/html", "keyword"); + check_submission("", "foo", "text/html", "invalid"); + + // See bug 1485508 + Assert.ok(!engine.searchForm.includes("?&")); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_region_params.js b/toolkit/components/search/tests/xpcshell/test_region_params.js new file mode 100644 index 0000000000..adf8d2cf49 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_region_params.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const CONFIG = [ + { + webExtension: { id: "engine@search.mozilla.org" }, + appliesTo: [ + { included: { everywhere: true } }, + { + included: { everywhere: true }, + experiment: "acohortid", + regionParams: { US: [{ name: "client", value: "veryspecial" }] }, + }, + ], + default: "yes", + params: { + searchUrlGetParams: [{ name: "client", value: "default" }], + }, + regionParams: { + US: [{ name: "client", value: "special" }], + }, + }, +]; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data", null, CONFIG); + await AddonTestUtils.promiseStartupManager(); + + let confUrl = `data:application/json,${JSON.stringify(CONFIG)}`; + Services.prefs.setStringPref("search.config.url", confUrl); +}); + +add_task(async function test_region_params() { + Region._setCurrentRegion("GB"); + await Services.search.init(); + let engine = await Services.search.getDefault(); + let params = engine.getSubmission("test").uri.query.split("&"); + Assert.ok(params.includes("client=default"), "Correct default params"); + + Region._setCurrentRegion("US"); + engine = await Services.search.getDefault(); + params = engine.getSubmission("test").uri.query.split("&"); + Assert.ok(params.includes("client=special"), "Override param in US"); + + Region._setCurrentRegion("ES"); + engine = await Services.search.getDefault(); + params = engine.getSubmission("test").uri.query.split("&"); + Assert.ok(params.includes("client=default"), "Revert back to default"); + + const reloadObserved = SearchTestUtils.promiseSearchNotification( + "engines-reloaded" + ); + Services.prefs.setCharPref("browser.search.experiment", "acohortid"); + await reloadObserved; + + Region._setCurrentRegion("US"); + engine = await Services.search.getDefault(); + params = engine.getSubmission("test").uri.query.split("&"); + Assert.ok( + params.includes("client=veryspecial"), + "appliesTo section param override used" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_reload_engines.js b/toolkit/components/search/tests/xpcshell/test_reload_engines.js new file mode 100644 index 0000000000..87f9187bf6 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_reload_engines.js @@ -0,0 +1,340 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchEngineSelector } = ChromeUtils.import( + "resource://gre/modules/SearchEngineSelector.jsm" +); +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +const SEARCH_SERVICE_TOPIC = "browser-search-service"; +const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified"; + +const CONFIG = [ + { + // Engine initially default, but the defaults will be changed to engine-pref. + webExtension: { + id: "engine@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + default: "yes", + defaultPrivate: "yes", + }, + { + included: { regions: ["FR"] }, + default: "no", + defaultPrivate: "no", + }, + ], + }, + { + // This will become defaults when region is changed to FR. + webExtension: { + id: "engine-pref@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + { + included: { regions: ["FR"] }, + default: "yes", + defaultPrivate: "yes", + }, + ], + }, + { + // This engine will get an update when region is changed to FR. + webExtension: { + id: "engine-chromeicon@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + { + included: { regions: ["FR"] }, + extraParams: [ + { name: "c", value: "my-test" }, + { name: "q1", value: "{searchTerms}" }, + ], + }, + ], + }, + { + // This engine will be removed when the region is changed to FR. + webExtension: { + id: "engine-rel-searchform-purpose@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + excluded: { regions: ["FR"] }, + }, + ], + }, + { + // This engine will be added when the region is changed to FR. + webExtension: { + id: "engine-reordered@search.mozilla.org", + }, + appliesTo: [ + { + included: { regions: ["FR"] }, + }, + ], + }, + { + // This engine will be re-ordered and have a changed name, when moved to FR. + webExtension: { + id: "engine-resourceicon@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + excluded: { regions: ["FR"] }, + }, + { + included: { regions: ["FR"] }, + webExtension: { + locales: ["gd"], + }, + orderHint: 30, + }, + ], + }, + { + // This engine has the same name, but still should be replaced correctly. + webExtension: { + id: "engine-same-name@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + excluded: { regions: ["FR"] }, + }, + { + included: { regions: ["FR"] }, + webExtension: { + locales: ["gd"], + }, + }, + ], + }, +]; + +function listenFor(name, key) { + let notifyObserved = false; + let obs = (subject, topic, data) => { + if (data == key) { + notifyObserved = true; + } + }; + Services.obs.addObserver(obs, name); + + return () => { + Services.obs.removeObserver(obs, name); + return notifyObserved; + }; +} + +async function visibleEngines() { + return (await Services.search.getVisibleEngines()).map(e => e.identifier); +} + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.search.separatePrivateDefault", true); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.useTestEngines("data", null, CONFIG); + await AddonTestUtils.promiseStartupManager(); +}); + +// This is to verify that the loaded configuration matches what we expect for +// the test. +add_task(async function test_initial_config_correct() { + Region._setHomeRegion("", false); + + await Services.search.init(); + + const installedEngines = await Services.search.getAppProvidedEngines(); + Assert.deepEqual( + installedEngines.map(e => e.identifier), + [ + "engine", + "engine-chromeicon", + "engine-pref", + "engine-rel-searchform-purpose", + "engine-resourceicon", + "engine-same-name", + ], + "Should have the correct list of engines installed." + ); + + Assert.equal( + (await Services.search.getDefault()).identifier, + "engine", + "Should have loaded the expected default engine" + ); + + Assert.equal( + (await Services.search.getDefaultPrivate()).identifier, + "engine", + "Should have loaded the expected private default engine" + ); +}); + +add_task(async function test_config_updated_engine_changes() { + // Update the config. + const reloadObserved = SearchTestUtils.promiseSearchNotification( + "engines-reloaded" + ); + const defaultEngineChanged = SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.DEFAULT, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); + const defaultPrivateEngineChanged = SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); + + const enginesAdded = []; + const enginesModified = []; + const enginesRemoved = []; + + function enginesObs(subject, topic, data) { + if (data == SearchUtils.MODIFIED_TYPE.ADDED) { + enginesAdded.push(subject.QueryInterface(Ci.nsISearchEngine).identifier); + } else if (data == SearchUtils.MODIFIED_TYPE.CHANGED) { + enginesModified.push( + subject.QueryInterface(Ci.nsISearchEngine).identifier + ); + } else if (data == SearchUtils.MODIFIED_TYPE.REMOVED) { + enginesRemoved.push(subject.QueryInterface(Ci.nsISearchEngine).name); + } + } + Services.obs.addObserver(enginesObs, SearchUtils.TOPIC_ENGINE_MODIFIED); + + Region._setHomeRegion("FR", false); + + await Services.search.wrappedJSObject._maybeReloadEngines(); + + await reloadObserved; + Services.obs.removeObserver(enginesObs, SearchUtils.TOPIC_ENGINE_MODIFIED); + + Assert.deepEqual( + enginesAdded, + ["engine-resourceicon-gd", "engine-reordered"], + "Should have added the correct engines" + ); + + Assert.deepEqual( + enginesModified.sort(), + ["engine", "engine-chromeicon", "engine-pref", "engine-same-name-gd"], + "Should have modified the expected engines" + ); + + Assert.deepEqual( + enginesRemoved, + ["engine-rel-searchform-purpose", "engine-resourceicon"], + "Should have removed the expected engine" + ); + + const installedEngines = await Services.search.getAppProvidedEngines(); + + Assert.deepEqual( + installedEngines.map(e => e.identifier), + [ + "engine-pref", + "engine-resourceicon-gd", + "engine-chromeicon", + "engine-same-name-gd", + "engine", + "engine-reordered", + ], + "Should have the correct list of engines installed in the expected order." + ); + + const newDefault = await defaultEngineChanged; + Assert.equal( + newDefault.QueryInterface(Ci.nsISearchEngine).name, + "engine-pref", + "Should have correctly notified the new default engine" + ); + + const newDefaultPrivate = await defaultPrivateEngineChanged; + Assert.equal( + newDefaultPrivate.QueryInterface(Ci.nsISearchEngine).name, + "engine-pref", + "Should have correctly notified the new default private engine" + ); + + const engineWithParams = await Services.search.getEngineByName( + "engine-chromeicon" + ); + Assert.equal( + engineWithParams.getSubmission("test").uri.spec, + "https://www.google.com/search?c=my-test&q1=test", + "Should have updated the parameters" + ); + + const engineWithSameName = await Services.search.getEngineByName( + "engine-same-name" + ); + Assert.equal( + engineWithSameName.getSubmission("test").uri.spec, + "https://www.example.com/search?q=test", + "Should have correctly switched to the engine of the same name" + ); + + Assert.equal( + Services.search.wrappedJSObject._settings.getAttribute("useSavedOrder"), + false, + "Should not have set the useSavedOrder preference" + ); +}); + +add_task(async function test_user_settings_persist() { + let reload = SearchTestUtils.promiseSearchNotification("engines-reloaded"); + Region._setHomeRegion(""); + await reload; + + Assert.ok( + (await visibleEngines()).includes("engine-rel-searchform-purpose"), + "Rel Searchform engine should be included by default" + ); + + let settingsFileWritten = promiseAfterSettings(); + let engine = await Services.search.getEngineByName( + "engine-rel-searchform-purpose" + ); + await Services.search.removeEngine(engine); + await settingsFileWritten; + + Assert.ok( + !(await visibleEngines()).includes("engine-rel-searchform-purpose"), + "Rel Searchform engine has been removed" + ); + + reload = SearchTestUtils.promiseSearchNotification("engines-reloaded"); + Region._setHomeRegion("FR"); + await reload; + + reload = SearchTestUtils.promiseSearchNotification("engines-reloaded"); + Region._setHomeRegion(""); + await reload; + + Assert.ok( + !(await visibleEngines()).includes("engine-rel-searchform-purpose"), + "Rel Searchform removal should be remembered" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_reload_engines_experiment.js b/toolkit/components/search/tests/xpcshell/test_reload_engines_experiment.js new file mode 100644 index 0000000000..62c03fc6ec --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_reload_engines_experiment.js @@ -0,0 +1,157 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchEngineSelector } = ChromeUtils.import( + "resource://gre/modules/SearchEngineSelector.jsm" +); +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +const SEARCH_SERVICE_TOPIC = "browser-search-service"; +const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified"; + +const CONFIG = [ + { + // Just a basic engine that won't be changed. + webExtension: { + id: "engine@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + default: "yes", + }, + ], + }, + { + // This engine will have the locale swapped when the experiment is set. + webExtension: { + id: "engine-same-name@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + webExtension: { + locales: ["en"], + }, + }, + { + included: { everywhere: true }, + webExtension: { + locales: ["gd"], + }, + experiment: "xpcshell", + }, + ], + }, +]; + +function listenFor(name, key) { + let notifyObserved = false; + let obs = (subject, topic, data) => { + if (data == key) { + notifyObserved = true; + } + }; + Services.obs.addObserver(obs, name); + + return () => { + Services.obs.removeObserver(obs, name); + return notifyObserved; + }; +} + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data", null, CONFIG); + await AddonTestUtils.promiseStartupManager(); +}); + +// This is to verify that the loaded configuration matches what we expect for +// the test. +add_task(async function test_initial_config_correct() { + await Services.search.init(); + + const installedEngines = await Services.search.getAppProvidedEngines(); + Assert.deepEqual( + installedEngines.map(e => e.identifier), + ["engine", "engine-same-name-en"], + "Should have the correct list of engines installed." + ); + + Assert.equal( + (await Services.search.getDefault()).identifier, + "engine", + "Should have loaded the expected default engine" + ); +}); + +add_task(async function test_config_updated_engine_changes() { + // Update the config. + const reloadObserved = SearchTestUtils.promiseSearchNotification( + "engines-reloaded" + ); + const enginesAdded = []; + const enginesModified = []; + const enginesRemoved = []; + + function enginesObs(subject, topic, data) { + if (data == SearchUtils.MODIFIED_TYPE.ADDED) { + enginesAdded.push(subject.QueryInterface(Ci.nsISearchEngine).identifier); + } else if (data == SearchUtils.MODIFIED_TYPE.CHANGED) { + enginesModified.push( + subject.QueryInterface(Ci.nsISearchEngine).identifier + ); + } else if (data == SearchUtils.MODIFIED_TYPE.REMOVED) { + enginesRemoved.push(subject.QueryInterface(Ci.nsISearchEngine).name); + } + } + Services.obs.addObserver(enginesObs, SearchUtils.TOPIC_ENGINE_MODIFIED); + + Services.prefs.setCharPref( + SearchUtils.BROWSER_SEARCH_PREF + "experiment", + "xpcshell" + ); + + await reloadObserved; + Services.obs.removeObserver(enginesObs, SearchUtils.TOPIC_ENGINE_MODIFIED); + + Assert.deepEqual(enginesAdded, [], "Should have added the correct engines"); + + Assert.deepEqual( + enginesModified.sort(), + ["engine", "engine-same-name-gd"], + "Should have modified the expected engines" + ); + + Assert.deepEqual( + enginesRemoved, + [], + "Should have removed the expected engine" + ); + + const installedEngines = await Services.search.getAppProvidedEngines(); + + Assert.deepEqual( + installedEngines.map(e => e.identifier), + ["engine", "engine-same-name-gd"], + "Should have the correct list of engines installed in the expected order." + ); + + const engineWithSameName = await Services.search.getEngineByName( + "engine-same-name" + ); + Assert.equal( + engineWithSameName.getSubmission("test").uri.spec, + "https://www.example.com/search?q=test", + "Should have correctly switched to the engine of the same name" + ); + + Assert.equal( + Services.search.wrappedJSObject._settings.getAttribute("useSavedOrder"), + false, + "Should not have set the useSavedOrder preference" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_remove_profile_engine.js b/toolkit/components/search/tests/xpcshell/test_remove_profile_engine.js new file mode 100644 index 0000000000..76a67b39c2 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_remove_profile_engine.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This test is to ensure that we remove xml files from searchplugins/ in the +// profile directory when a user removes the actual engine from their profile. + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data1"); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function run_test() { + // Copy an engine to [profile]/searchplugin/ + let dir = do_get_profile().clone(); + dir.append("searchplugins"); + if (!dir.exists()) { + dir.create(dir.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + } + do_get_file("data/engine.xml").copyTo(dir, "test-search-engine.xml"); + + let file = dir.clone(); + file.append("test-search-engine.xml"); + Assert.ok(file.exists()); + + let data = await readJSONFile(do_get_file("data/search-legacy.json")); + + // Put the filePath inside the settings file, to simulate what a pre-58 version + // of Firefox would have done. + for (let engine of data.engines) { + if (engine._name == "Test search engine") { + engine.filePath = file.path; + } + } + + await promiseSaveSettingsData(data); + + await Services.search.init(); + + // test the engine is loaded ok. + let engine = Services.search.getEngineByName("Test search engine"); + Assert.notEqual(engine, null, "Should have found the engine"); + + // remove the engine and verify the file has been removed too. + await Services.search.removeEngine(engine); + Assert.ok(!file.exists(), "Should have removed the file."); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_resultDomain.js b/toolkit/components/search/tests/xpcshell/test_resultDomain.js new file mode 100644 index 0000000000..027fda515e --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_resultDomain.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests getResultDomain API. + */ + +"use strict"; + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data", null); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_resultDomain() { + await Services.search.init(); + + let engine = Services.search.getEngineByName("Test search engine"); + + Assert.equal(engine.getResultDomain(), "www.google.com"); + Assert.equal(engine.getResultDomain("text/html"), "www.google.com"); + Assert.equal( + engine.getResultDomain("application/x-suggestions+json"), + "suggestqueries.google.com" + ); + Assert.equal(engine.getResultDomain("fake-response-type"), ""); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_save_sorted_engines.js b/toolkit/components/search/tests/xpcshell/test_save_sorted_engines.js new file mode 100644 index 0000000000..dc2a0686d9 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_save_sorted_engines.js @@ -0,0 +1,64 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Ensure that metadata are stored correctly on disk after: + * - moving an engine + * - removing an engine + * - adding a new engine + * + * Notes: + * - we install the search engines of test "test_downloadAndAddEngines.js" + * to ensure that this test is independent from locale, commercial agreements + * and configuration of Firefox. + */ + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_save_sorted_engines() { + let [engine1, engine2] = await addTestEngines([ + { name: "Test search engine", xmlFileName: "engine.xml" }, + { name: "A second test engine", xmlFileName: "engine2.xml" }, + ]); + await promiseAfterSettings(); + + let search = Services.search; + + // Test moving the engines + await search.moveEngine(engine1, 0); + await search.moveEngine(engine2, 1); + + // Changes should be commited immediately + await promiseAfterSettings(); + info("Commit complete after moveEngine"); + + // Check that the entries are placed as specified correctly + let metadata = await promiseEngineMetadata(); + Assert.equal(metadata["Test search engine"].order, 1); + Assert.equal(metadata["A second test engine"].order, 2); + + // Test removing an engine + search.removeEngine(engine1); + await promiseAfterSettings(); + info("Commit complete after removeEngine"); + + // Check that the order of the remaining engine was updated correctly + metadata = await promiseEngineMetadata(); + Assert.equal(metadata["A second test engine"].order, 1); + + // Test adding a new engine + let engine = await search.addEngineWithDetails("foo", { + alias: "foo", + method: "GET", + template: "http://searchget/?search={searchTerms}", + }); + await promiseAfterSettings(); + info("Commit complete after addEngineWithDetails"); + + metadata = await promiseEngineMetadata(); + Assert.ok(engine.aliases.includes("foo")); + Assert.ok(metadata.foo.order > 0); +}); 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 0000000000..d55f9d9707 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest.js @@ -0,0 +1,762 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +/** + * Testing search suggestions from SearchSuggestionController.jsm. + */ + +"use strict"; + +const { FormHistory } = ChromeUtils.import( + "resource://gre/modules/FormHistory.jsm" +); +const { SearchSuggestionController } = ChromeUtils.import( + "resource://gre/modules/SearchSuggestionController.jsm" +); +const { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +// We must make sure the FormHistoryStartup component is +// initialized in order for it to respond to FormHistory +// requests from nsFormAutoComplete.js. +var formHistoryStartup = Cc[ + "@mozilla.org/satchel/form-history-startup;1" +].getService(Ci.nsIObserver); +formHistoryStartup.observe(null, "profile-after-change", null); + +var getEngine, postEngine, unresolvableEngine, alternateJSONEngine; + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.search.suggest.enabled", true); + + let server = useHttpServer(); + server.registerContentType("sjs", "sjs"); + + await AddonTestUtils.promiseStartupManager(); + + registerCleanupFunction(async () => { + // Remove added form history entries + await updateSearchHistory("remove", null); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + }); +}); + +add_task(async function add_test_engines() { + let getEngineData = { + baseURL: gDataUrl, + name: "GET suggestion engine", + method: "GET", + }; + + let postEngineData = { + baseURL: gDataUrl, + name: "POST suggestion engine", + method: "POST", + }; + + let unresolvableEngineData = { + baseURL: "http://example.invalid/", + name: "Offline suggestion engine", + method: "GET", + }; + + let alternateJSONSuggestEngineData = { + baseURL: gDataUrl, + name: "Alternative JSON suggestion type", + method: "GET", + alternativeJSONType: true, + }; + + [ + getEngine, + postEngine, + unresolvableEngine, + alternateJSONEngine, + ] = await 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), + }, + { + name: alternateJSONSuggestEngineData.name, + xmlFileName: + "engineMaker.sjs?" + JSON.stringify(alternateJSONSuggestEngineData), + }, + ]); +}); + +// Begin tests + +add_task(async function simple_no_result_callback() { + await new Promise(resolve => { + let controller = new SearchSuggestionController(result => { + Assert.equal(result.term, "no remote"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 0); + resolve(); + }); + + controller.fetch("no remote", false, getEngine); + }); +}); + +add_task(async function simple_no_result_callback_and_promise() { + // Make sure both the callback and promise get results + let deferred = PromiseUtils.defer(); + let controller = new SearchSuggestionController(result => { + Assert.equal(result.term, "no results"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 0); + deferred.resolve(); + }); + + let result = await controller.fetch("no results", false, getEngine); + Assert.equal(result.term, "no results"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 0); + + await deferred.promise; +}); + +add_task(async function simple_no_result_promise() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("no remote", false, getEngine); + Assert.equal(result.term, "no remote"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 0); +}); + +add_task(async function simple_remote_no_local_result() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("mo", false, getEngine); + Assert.equal(result.term, "mo"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[0].value, "Mozilla"); + Assert.equal(result.remote[1].value, "modern"); + Assert.equal(result.remote[2].value, "mom"); +}); + +add_task(async function simple_remote_no_local_result_telemetry() { + Services.telemetry.clearScalars(); + + let controller = new SearchSuggestionController(); + await controller.fetch("mo", false, getEngine); + + let scalars = {}; + const key = "browser.search.data_transferred"; + + await TestUtils.waitForCondition(() => { + scalars = + Services.telemetry.getSnapshotForKeyedScalars("main", false).parent || {}; + return key in scalars; + }, "should have the expected keyed scalars"); + + const scalar = scalars[key]; + Assert.ok("sggt-other" in scalar, "correct telemetry category"); + Assert.notEqual(scalar["sggt-other"], 0, "bandwidth logged"); +}); + +add_task(async function simple_remote_no_local_result_alternative_type() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("mo", false, alternateJSONEngine); + Assert.equal(result.term, "mo"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[0].value, "Mozilla"); + Assert.equal(result.remote[1].value, "modern"); + Assert.equal(result.remote[2].value, "mom"); +}); + +add_task(async function remote_term_case_mismatch() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("Query Case Mismatch", false, getEngine); + Assert.equal(result.term, "Query Case Mismatch"); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "Query Case Mismatch"); +}); + +add_task(async function simple_local_no_remote_result() { + await updateSearchHistory("bump", "no remote entries"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("no remote", false, getEngine); + Assert.equal(result.term, "no remote"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "no remote entries"); + Assert.equal(result.remote.length, 0); + + await updateSearchHistory("remove", "no remote entries"); +}); + +add_task(async function simple_non_ascii() { + await updateSearchHistory("bump", "I ❤️ XUL"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("I ❤️", false, getEngine); + Assert.equal(result.term, "I ❤️"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "I ❤️ XUL"); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "I ❤️ Mozilla"); +}); + +add_task(async function both_local_remote_result_dedupe() { + await updateSearchHistory("bump", "Mozilla"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("mo", false, getEngine); + Assert.equal(result.term, "mo"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "Mozilla"); + Assert.equal(result.remote.length, 2); + Assert.equal(result.remote[0].value, "modern"); + Assert.equal(result.remote[1].value, "mom"); +}); + +add_task(async function POST_both_local_remote_result_dedupe() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("mo", false, postEngine); + Assert.equal(result.term, "mo"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "Mozilla"); + Assert.equal(result.remote.length, 2); + Assert.equal(result.remote[0].value, "modern"); + Assert.equal(result.remote[1].value, "mom"); +}); + +add_task(async function both_local_remote_result_dedupe2() { + await updateSearchHistory("bump", "mom"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("mo", false, getEngine); + Assert.equal(result.term, "mo"); + Assert.equal(result.local.length, 2); + Assert.equal(result.local[0].value, "mom"); + Assert.equal(result.local[1].value, "Mozilla"); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "modern"); +}); + +add_task(async function both_local_remote_result_dedupe3() { + // All of the server entries also exist locally + await updateSearchHistory("bump", "modern"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("mo", false, getEngine); + Assert.equal(result.term, "mo"); + Assert.equal(result.local.length, 3); + Assert.equal(result.local[0].value, "modern"); + Assert.equal(result.local[1].value, "mom"); + Assert.equal(result.local[2].value, "Mozilla"); + Assert.equal(result.remote.length, 0); +}); + +add_task(async function valid_tail_results() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("tail query", false, getEngine); + Assert.equal(result.term, "tail query"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[0].value, "tail query normal"); + Assert.ok(!result.remote[0].matchPrefix); + Assert.ok(!result.remote[0].tail); + Assert.equal(result.remote[1].value, "tail query tail 1"); + Assert.equal(result.remote[1].matchPrefix, "… "); + Assert.equal(result.remote[1].tail, "tail 1"); + Assert.equal(result.remote[2].value, "tail query tail 2"); + Assert.equal(result.remote[2].matchPrefix, "… "); + Assert.equal(result.remote[2].tail, "tail 2"); +}); + +add_task(async function alt_tail_results() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("tailalt query", false, getEngine); + Assert.equal(result.term, "tailalt query"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[0].value, "tailalt query normal"); + Assert.ok(!result.remote[0].matchPrefix); + Assert.ok(!result.remote[0].tail); + Assert.equal(result.remote[1].value, "tailalt query tail 1"); + Assert.equal(result.remote[1].matchPrefix, "… "); + Assert.equal(result.remote[1].tail, "tail 1"); + Assert.equal(result.remote[2].value, "tailalt query tail 2"); + Assert.equal(result.remote[2].matchPrefix, "… "); + Assert.equal(result.remote[2].tail, "tail 2"); +}); + +add_task(async function invalid_tail_results() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("tailjunk query", false, getEngine); + Assert.equal(result.term, "tailjunk query"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[0].value, "tailjunk query normal"); + Assert.ok(!result.remote[0].matchPrefix); + Assert.ok(!result.remote[0].tail); + Assert.equal(result.remote[1].value, "tailjunk query tail 1"); + Assert.ok(!result.remote[1].matchPrefix); + Assert.ok(!result.remote[1].tail); + Assert.equal(result.remote[2].value, "tailjunk query tail 2"); + Assert.ok(!result.remote[2].matchPrefix); + Assert.ok(!result.remote[2].tail); +}); + +add_task(async function too_few_tail_results() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("tailjunk few query", false, getEngine); + Assert.equal(result.term, "tailjunk few query"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[0].value, "tailjunk few query normal"); + Assert.ok(!result.remote[0].matchPrefix); + Assert.ok(!result.remote[0].tail); + Assert.equal(result.remote[1].value, "tailjunk few query tail 1"); + Assert.ok(!result.remote[1].matchPrefix); + Assert.ok(!result.remote[1].tail); + Assert.equal(result.remote[2].value, "tailjunk few query tail 2"); + Assert.ok(!result.remote[2].matchPrefix); + Assert.ok(!result.remote[2].tail); +}); + +add_task(async function empty_rich_results() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("richempty query", false, getEngine); + Assert.equal(result.term, "richempty query"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[0].value, "richempty query normal"); + Assert.ok(!result.remote[0].matchPrefix); + Assert.ok(!result.remote[0].tail); + Assert.equal(result.remote[1].value, "richempty query tail 1"); + Assert.ok(!result.remote[1].matchPrefix); + Assert.ok(!result.remote[1].tail); + Assert.equal(result.remote[2].value, "richempty query tail 2"); + Assert.ok(!result.remote[2].matchPrefix); + Assert.ok(!result.remote[2].tail); +}); + +add_task(async function tail_offset_index() { + let controller = new SearchSuggestionController(); + let result = await controller.fetch("tail tail 1 t", false, getEngine); + Assert.equal(result.term, "tail tail 1 t"); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 3); + Assert.equal(result.remote[1].value, "tail tail 1 t tail 1"); + Assert.equal(result.remote[1].matchPrefix, "… "); + Assert.equal(result.remote[1].tail, "tail 1"); + Assert.equal(result.remote[1].tailOffsetIndex, 14); +}); + +add_task(async function fetch_twice_in_a_row() { + // Two entries since the first will match the first fetch but not the second. + await updateSearchHistory("bump", "delay local"); + await 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); + await resultPromise1.then(results => Assert.equal(null, results)); + + let result = await resultPromise2; + Assert.equal(result.term, "delayed "); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "delayed local"); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "delayed "); +}); + +add_task(async 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. + await updateSearchHistory("bump", "delay local"); + await updateSearchHistory("bump", "delayed local"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("delay", false, getEngine); + Assert.equal(result.term, "delay"); + Assert.equal(result.local.length, 2); + Assert.equal(result.local[0].value, "delay local"); + Assert.equal(result.local[1].value, "delayed local"); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "delay"); + + // Remove the entry from the DB but it should remain in the cached formHistoryResult. + await updateSearchHistory("remove", "delayed local"); + + let result2 = await controller.fetch("delayed ", false, getEngine); + Assert.equal(result2.term, "delayed "); + Assert.equal(result2.local.length, 1); + Assert.equal(result2.local[0].value, "delayed local"); + Assert.equal(result2.remote.length, 1); + Assert.equal(result2.remote[0].value, "delayed "); +}); + +add_task(async 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++ + ) { + await updateSearchHistory( + "bump", + "letter " + String.fromCharCode(charCode) + ); + } + + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 7; + controller.maxRemoteResults = 10; + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 7); + for (let i = 0; i < controller.maxLocalResults; i++) { + Assert.equal( + result.local[i].value, + "letter " + String.fromCharCode("A".charCodeAt() + i) + ); + } + Assert.equal(result.local.length + result.remote.length, 10); + for (let i = 0; i < result.remote.length; i++) { + Assert.equal( + result.remote[i].value, + "letter " + + String.fromCharCode("A".charCodeAt() + controller.maxLocalResults + i) + ); + } +}); + +add_task(async function noremote_maxLocal() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 2; // (should be ignored because no remote results) + controller.maxRemoteResults = 0; + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 26); + for (let i = 0; i < result.local.length; i++) { + Assert.equal( + result.local[i].value, + "letter " + String.fromCharCode("A".charCodeAt() + i) + ); + } + Assert.equal(result.remote.length, 0); +}); + +add_task(async function someremote_maxLocal() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 2; + controller.maxRemoteResults = 4; + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 2); + for (let i = 0; i < result.local.length; i++) { + Assert.equal( + result.local[i].value, + "letter " + String.fromCharCode("A".charCodeAt() + i) + ); + } + Assert.equal(result.remote.length, 2); + // "A" and "B" will have been de-duped, start at C for remote results + for (let i = 0; i < result.remote.length; i++) { + Assert.equal( + result.remote[i].value, + "letter " + String.fromCharCode("C".charCodeAt() + i) + ); + } +}); + +add_task(async function one_of_each() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 1; + controller.maxRemoteResults = 2; + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "letter A"); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "letter B"); +}); + +add_task(async function local_result_returned_remote_result_disabled() { + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 1; + controller.maxRemoteResults = 1; + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 26); + for (let i = 0; i < 26; i++) { + Assert.equal( + result.local[i].value, + "letter " + String.fromCharCode("A".charCodeAt() + i) + ); + } + Assert.equal(result.remote.length, 0); + Services.prefs.setBoolPref("browser.search.suggest.enabled", true); +}); + +add_task( + async function local_result_returned_remote_result_disabled_after_creation_of_controller() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 1; + controller.maxRemoteResults = 1; + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 26); + for (let i = 0; i < 26; i++) { + Assert.equal( + result.local[i].value, + "letter " + String.fromCharCode("A".charCodeAt() + i) + ); + } + Assert.equal(result.remote.length, 0); + Services.prefs.setBoolPref("browser.search.suggest.enabled", true); + } +); + +add_task( + async function one_of_each_disabled_before_creation_enabled_after_creation_of_controller() { + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 1; + controller.maxRemoteResults = 2; + Services.prefs.setBoolPref("browser.search.suggest.enabled", true); + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "letter A"); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "letter B"); + + Services.prefs.setBoolPref("browser.search.suggest.enabled", true); + } +); + +add_task(async function one_local_zero_remote() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 1; + controller.maxRemoteResults = 0; + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 26); + for (let i = 0; i < 26; i++) { + Assert.equal( + result.local[i].value, + "letter " + String.fromCharCode("A".charCodeAt() + i) + ); + } + Assert.equal(result.remote.length, 0); +}); + +add_task(async function zero_local_one_remote() { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 0; + controller.maxRemoteResults = 1; + let result = await controller.fetch("letter ", false, getEngine); + Assert.equal(result.term, "letter "); + Assert.equal(result.local.length, 0); + Assert.equal(result.remote.length, 1); + Assert.equal(result.remote[0].value, "letter A"); +}); + +add_task(async 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(); + await resultPromise.then(result => { + Assert.equal(null, result); + }); +}); + +add_task(async function empty_searchTerm() { + // Empty searches don't go to the server but still get form history. + let controller = new SearchSuggestionController(); + let result = await controller.fetch("", false, getEngine); + Assert.equal(result.term, ""); + Assert.ok(!!result.local.length); + Assert.equal(result.remote.length, 0); +}); + +add_task(async function slow_timeout() { + let d = PromiseUtils.defer(); + function check_result(result) { + Assert.equal(result.term, "slow "); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "slow local result"); + Assert.equal(result.remote.length, 0); + } + await 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 = await controller.fetch("slow ", false, getEngine); + check_result(result); + await d.promise; +}); + +add_task(async function slow_stop() { + let d = PromiseUtils.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); + await resultPromise.then(result => { + Assert.equal(null, result); + }); + + await d.promise; +}); + +// Error handling + +add_task(async function remote_term_mismatch() { + await updateSearchHistory("bump", "Query Mismatch Entry"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("Query Mismatch", false, getEngine); + Assert.equal(result.term, "Query Mismatch"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "Query Mismatch Entry"); + Assert.equal(result.remote.length, 0); +}); + +add_task(async function http_404() { + await updateSearchHistory("bump", "HTTP 404 Entry"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("HTTP 404", false, getEngine); + Assert.equal(result.term, "HTTP 404"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "HTTP 404 Entry"); + Assert.equal(result.remote.length, 0); +}); + +add_task(async function http_500() { + await updateSearchHistory("bump", "HTTP 500 Entry"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch("HTTP 500", false, getEngine); + Assert.equal(result.term, "HTTP 500"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "HTTP 500 Entry"); + Assert.equal(result.remote.length, 0); +}); + +add_task(async function unresolvable_server() { + await updateSearchHistory("bump", "Unresolvable Server Entry"); + + let controller = new SearchSuggestionController(); + let result = await controller.fetch( + "Unresolvable Server", + false, + unresolvableEngine + ); + Assert.equal(result.term, "Unresolvable Server"); + Assert.equal(result.local.length, 1); + Assert.equal(result.local[0].value, "Unresolvable Server Entry"); + Assert.equal(result.remote.length, 0); +}); + +// Exception handling + +add_task(async function missing_pb() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.fetch("No privacy"); + }, /priva/i); +}); + +add_task(async function missing_engine() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.fetch("No engine", false); + }, /engine/i); +}); + +add_task(async function invalid_engine() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.fetch("invalid engine", false, {}); + }, /engine/i); +}); + +add_task(async 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(async function minus_one_results_requested() { + Assert.throws(() => { + let controller = new SearchSuggestionController(); + controller.maxLocalResults = -1; + controller.fetch("-1 results requested", false, getEngine); + }, /result/i); +}); + +add_task(async function test_userContextId() { + let controller = new SearchSuggestionController(); + controller._fetchRemote = function( + searchTerm, + engine, + privateMode, + userContextId + ) { + Assert.equal(userContextId, 1); + return PromiseUtils.defer(); + }; + + controller.fetch("test", false, getEngine, 1); +}); + +// Helpers + +function updateSearchHistory(operation, value) { + return new Promise((resolve, reject) => { + FormHistory.update( + { + op: operation, + fieldname: "searchbar-history", + value, + }, + { + handleError(error) { + do_throw("Error occurred updating form history: " + error); + reject(error); + }, + handleCompletion(reason) { + if (!reason) { + resolve(); + } + }, + } + ); + }); +} diff --git a/toolkit/components/search/tests/xpcshell/test_searchSuggest_cookies.js b/toolkit/components/search/tests/xpcshell/test_searchSuggest_cookies.js new file mode 100644 index 0000000000..f2d1e15597 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest_cookies.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that search suggestions from SearchSuggestionController.jsm don't store + * cookies. + */ + +"use strict"; + +const { SearchSuggestionController } = ChromeUtils.import( + "resource://gre/modules/SearchSuggestionController.jsm" +); + +// We must make sure the FormHistoryStartup component is +// initialized in order for it to respond to FormHistory +// requests from nsFormAutoComplete.js. +var formHistoryStartup = Cc[ + "@mozilla.org/satchel/form-history-startup;1" +].getService(Ci.nsIObserver); +formHistoryStartup.observe(null, "profile-after-change", null); + +function countCacheEntries() { + info("Enumerating cache entries"); + return new Promise(resolve => { + let storage = Services.cache2.diskCacheStorage( + Services.loadContextInfo.default, + false + ); + storage.asyncVisitStorage( + { + onCacheStorageInfo(num, consumption) { + this._num = num; + }, + onCacheEntryInfo(uri) { + info("Found cache entry: " + uri.asciiSpec); + }, + onCacheEntryVisitCompleted() { + resolve(this._num || 0); + }, + }, + true /* Do walk entries */ + ); + }); +} + +function countCookieEntries() { + info("Enumerating cookies"); + let cookies = Services.cookies.cookies; + let cookieCount = 0; + for (let cookie of cookies) { + info( + "Cookie:" + cookie.rawHost + " " + JSON.stringify(cookie.originAttributes) + ); + cookieCount++; + break; + } + return cookieCount; +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + Services.prefs.setBoolPref("browser.search.suggest.enabled", true); + Services.prefs.setBoolPref("browser.search.suggest.enabled.private", true); + + registerCleanupFunction(async () => { + // Clean up all the data. + await new Promise(resolve => + Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve) + ); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + Services.prefs.clearUserPref("browser.search.suggest.enabled.private"); + }); + + let server = useHttpServer(); + server.registerContentType("sjs", "sjs"); + + let unicodeName = ["\u30a8", "\u30c9"].join(""); + let engines = await addTestEngines([ + { + name: unicodeName, + xmlFileName: + "engineMaker.sjs?" + + JSON.stringify({ + baseURL: gDataUrl, + name: unicodeName, + method: "GET", + }), + }, + { + name: "engine two", + xmlFileName: + "engineMaker.sjs?" + + JSON.stringify({ + baseURL: gDataUrl, + name: "engine two", + method: "GET", + }), + }, + ]); + + // Clean up all the data. + await new Promise(resolve => + Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve) + ); + Assert.equal(await countCacheEntries(), 0, "The cache should be empty"); + Assert.equal(await countCookieEntries(), 0, "Should not find any cookie"); + + await test_engine(engines, true); + await test_engine(engines, false); +}); + +async function test_engine(engines, privateMode) { + info(`Testing ${privateMode ? "private" : "normal"} mode`); + let controller; + await new Promise(resolve => { + controller = new SearchSuggestionController(result => { + Assert.equal(result.local.length, 0, "Should have no local suggestions"); + Assert.equal( + result.remote.length, + 0, + "Should have no remote suggestions" + ); + if (result.term == "cookie") { + resolve(); + } + }); + controller.fetch("test", privateMode, engines[0]); + controller.fetch("cookie", privateMode, engines[1]); + }); + Assert.equal(await countCacheEntries(), 0, "The cache should be empty"); + Assert.equal(await countCookieEntries(), 0, "Should not find any cookie"); + + let firstPartyDomain1 = controller.firstPartyDomains.get(engines[0].name); + Assert.ok( + /^[\.a-z0-9-]+\.search\.suggestions\.mozilla/.test(firstPartyDomain1), + "Check firstPartyDomain1" + ); + + let firstPartyDomain2 = controller.firstPartyDomains.get(engines[1].name); + Assert.ok( + /^[\.a-z0-9-]+\.search\.suggestions\.mozilla/.test(firstPartyDomain2), + "Check firstPartyDomain2" + ); + + Assert.notEqual( + firstPartyDomain1, + firstPartyDomain2, + "Check firstPartyDomain id unique per engine" + ); +} diff --git a/toolkit/components/search/tests/xpcshell/test_searchSuggest_private.js b/toolkit/components/search/tests/xpcshell/test_searchSuggest_private.js new file mode 100644 index 0000000000..ffd4706690 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest_private.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test that search suggestions from SearchSuggestionController.jsm operate + * correctly in private mode. + */ + +"use strict"; + +const { SearchSuggestionController } = ChromeUtils.import( + "resource://gre/modules/SearchSuggestionController.jsm" +); + +let engine; + +add_task(async function setup() { + Services.prefs.setBoolPref("browser.search.suggest.enabled", true); + + let server = useHttpServer(); + server.registerContentType("sjs", "sjs"); + + await AddonTestUtils.promiseStartupManager(); + + const engineData = { + baseURL: gDataUrl, + name: "GET suggestion engine", + method: "GET", + }; + + [engine] = await addTestEngines([ + { + name: engineData.name, + xmlFileName: "engineMaker.sjs?" + JSON.stringify(engineData), + }, + ]); +}); + +add_task(async function test_suggestions_in_private_mode_enabled() { + Services.prefs.setBoolPref("browser.search.suggest.enabled.private", true); + + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 0; + controller.maxRemoteResults = 1; + let result = await controller.fetch("mo", true, engine); + Assert.equal(result.remote.length, 1); +}); + +add_task(async function test_suggestions_in_private_mode_disabled() { + Services.prefs.setBoolPref("browser.search.suggest.enabled.private", false); + + let controller = new SearchSuggestionController(); + controller.maxLocalResults = 0; + controller.maxRemoteResults = 1; + let result = await controller.fetch("mo", true, engine); + Assert.equal(result.remote.length, 0); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_selectedEngine.js b/toolkit/components/search/tests/xpcshell/test_selectedEngine.js new file mode 100644 index 0000000000..7f1e7f318b --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_selectedEngine.js @@ -0,0 +1,139 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const kDefaultEngineName = "engine1"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await SearchTestUtils.useTestEngines("data1"); + Assert.ok(!Services.search.isInitialized); +}); + +// Check that the default engine matches the defaultenginename pref +add_task(async function test_defaultEngine() { + await Services.search.init(); + await installTestEngine(); + + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); +}); + +// Setting the search engine should be persisted across restarts. +add_task(async function test_persistAcrossRestarts() { + // Set the engine through the API. + await Services.search.setDefault( + Services.search.getEngineByName(kTestEngineName) + ); + Assert.equal(Services.search.defaultEngine.name, kTestEngineName); + await promiseAfterSettings(); + + // Check that the a hash was saved. + let metadata = await promiseGlobalMetadata(); + Assert.equal(metadata.hash.length, 44); + + // Re-init and check the engine is still the same. + Services.search.wrappedJSObject.reset(); + await Services.search.init(true); + Assert.equal(Services.search.defaultEngine.name, kTestEngineName); + + // Cleanup (set the engine back to default). + Services.search.resetToOriginalDefaultEngine(); + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); +}); + +// An engine set without a valid hash should be ignored. +add_task(async function test_ignoreInvalidHash() { + // Set the engine through the API. + await Services.search.setDefault( + Services.search.getEngineByName(kTestEngineName) + ); + Assert.equal(Services.search.defaultEngine.name, kTestEngineName); + await promiseAfterSettings(); + + // Then mess with the file (make the hash invalid). + let metadata = await promiseGlobalMetadata(); + metadata.hash = "invalid"; + await promiseSaveGlobalMetadata(metadata); + + // Re-init the search service, and check that the json file is ignored. + Services.search.wrappedJSObject.reset(); + await Services.search.init(true); + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); +}); + +// Resetting the engine to the default should remove the saved value. +add_task(async function test_settingToDefault() { + // Set the engine through the API. + await Services.search.setDefault( + Services.search.getEngineByName(kTestEngineName) + ); + Assert.equal(Services.search.defaultEngine.name, kTestEngineName); + await promiseAfterSettings(); + + // Check that the current engine was saved. + let metadata = await promiseGlobalMetadata(); + Assert.equal(metadata.current, kTestEngineName); + + // Then set the engine back to the default through the API. + await Services.search.setDefault( + Services.search.getEngineByName(kDefaultEngineName) + ); + await promiseAfterSettings(); + + // Check that the current engine is no longer saved in the JSON file. + metadata = await promiseGlobalMetadata(); + Assert.equal(metadata.current, ""); +}); + +add_task(async function test_resetToOriginalDefaultEngine() { + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); + + await Services.search.setDefault( + Services.search.getEngineByName(kTestEngineName) + ); + Assert.equal(Services.search.defaultEngine.name, kTestEngineName); + await promiseAfterSettings(); + + Services.search.resetToOriginalDefaultEngine(); + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); + await promiseAfterSettings(); +}); + +add_task(async function test_fallback_kept_after_restart() { + // Set current engine to a default engine that isn't the original default. + let builtInEngines = await Services.search.getAppProvidedEngines(); + let nonDefaultBuiltInEngine; + for (let engine of builtInEngines) { + if (engine.name != kDefaultEngineName) { + nonDefaultBuiltInEngine = engine; + break; + } + } + await Services.search.setDefault(nonDefaultBuiltInEngine); + Assert.equal( + Services.search.defaultEngine.name, + nonDefaultBuiltInEngine.name + ); + await promiseAfterSettings(); + + // Remove that engine... + await Services.search.removeEngine(nonDefaultBuiltInEngine); + // The engine being a default (built-in) one, it should be hidden + // rather than actually removed. + Assert.ok(nonDefaultBuiltInEngine.hidden); + + // Using the defaultEngine getter should force a fallback to the + // original default engine. + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); + + // Restoring the default engines should unhide our built-in test + // engine, but not change the value of defaultEngine. + Services.search.restoreDefaultEngines(); + Assert.ok(!nonDefaultBuiltInEngine.hidden); + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); + await promiseAfterSettings(); + + // After a restart, the defaultEngine value should still be unchanged. + Services.search.wrappedJSObject.reset(); + await Services.search.init(true); + Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_sendSubmissionURL.js b/toolkit/components/search/tests/xpcshell/test_sendSubmissionURL.js new file mode 100644 index 0000000000..affda64adc --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_sendSubmissionURL.js @@ -0,0 +1,78 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests covering sending submission URLs for major engines + */ + +const SUBMISSION_YES = new Map([ + ["Google1 Test", "https://www.google.com/search?q={searchTerms}"], + ["Google2 Test", "https://www.google.co.uk/search?q={searchTerms}"], + ["Yahoo1 Test", "https://search.yahoo.com/search?p={searchTerms}"], + ["Yahoo2 Test", "https://uk.search.yahoo.com/search?p={searchTerms}"], + ["AOL1 Test", "https://search.aol.com/aol/search?q={searchTerms}"], + ["AOL2 Test", "https://search.aol.co.uk/aol/search?q={searchTerms}"], + ["Yandex1 Test", "https://yandex.ru/search/?text={searchTerms}"], + ["Yandex2 Test", "https://yandex.com/search/?text{searchTerms}"], + ["Ask1 Test", "https://www.ask.com/web?q={searchTerms}"], + ["Ask2 Test", "https://fr.ask.com/web?q={searchTerms}"], + ["Bing Test", "https://www.bing.com/search?q={searchTerms}"], + ["Startpage Test", "https://www.startpage.com/do/search?query={searchTerms}"], + ["DuckDuckGo Test", "https://duckduckgo.com/?q={searchTerms}"], + ["Baidu Test", "https://www.baidu.com/s?wd={searchTerms}"], +]); + +const SUBMISSION_NO = new Map([ + ["Other1 Test", "https://example.com?q={searchTerms}"], + ["Other2 Test", "https://googlebutnotgoogle.com?q={searchTerms}"], +]); + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data1"); + await AddonTestUtils.promiseStartupManager(); +}); + +async function addAndMakeDefault(name, searchURL) { + await Services.search.addEngineWithDetails(name, { + method: "GET", + template: searchURL, + }); + let engine = Services.search.getEngineByName(name); + await Services.search.setDefault(engine); + return engine; +} + +add_task(async function test_submission_url_matching() { + Assert.ok(!Services.search.isInitialized); + let engineInfo; + let engine; + + for (let [name, searchURL] of SUBMISSION_YES) { + engine = await addAndMakeDefault(name, searchURL); + engineInfo = await Services.search.getDefaultEngineInfo(); + Assert.equal( + engineInfo.defaultSearchEngineData.submissionURL, + searchURL.replace("{searchTerms}", "") + ); + await Services.search.removeEngine(engine); + } + + for (let [name, searchURL] of SUBMISSION_NO) { + engine = await addAndMakeDefault(name, searchURL); + engineInfo = await Services.search.getDefaultEngineInfo(); + Assert.equal(engineInfo.defaultSearchEngineData.submissionURL, null); + await Services.search.removeEngine(engine); + } +}); + +add_task(async function test_submission_url_built_in() { + const engine = await Services.search.getEngineByName("engine1"); + await Services.search.setDefault(engine); + + const engineInfo = await Services.search.getDefaultEngineInfo(); + Assert.equal( + engineInfo.defaultSearchEngineData.submissionURL, + "https://1.example.com/search?q=", + "Should have given the submission url for a built-in engine." + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_settings.js b/toolkit/components/search/tests/xpcshell/test_settings.js new file mode 100644 index 0000000000..a2cc9abd3a --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings.js @@ -0,0 +1,312 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test initializing from the search settings. + */ + +"use strict"; + +const { getAppInfo } = ChromeUtils.import( + "resource://testing-common/AppInfo.jsm" +); + +const legacyUseSavedOrderPrefName = + SearchUtils.BROWSER_SEARCH_PREF + "useDBForOrder"; + +var settingsTemplate; + +/** + * Test reading from search.json.mozlz4 + */ +add_task(async function setup() { + Services.prefs + .getDefaultBranch(SearchUtils.BROWSER_SEARCH_PREF + "param.") + .setCharPref("test", "expected"); + + await SearchTestUtils.useTestEngines("data1"); + await AddonTestUtils.promiseStartupManager(); +}); + +async function loadSettingsFile(settingsFile, setVersion) { + settingsTemplate = await readJSONFile(do_get_file(settingsFile)); + if (setVersion) { + settingsTemplate.version = SearchUtils.SETTINGS_VERSION; + } + + delete settingsTemplate.visibleDefaultEngines; + + await promiseSaveSettingsData(settingsTemplate); +} + +/** + * Start the search service and confirm the engine properties match the expected values. + * + * @param {string} settingsFile + * The path to the settings file to use. + * @param {boolean} setVersion + * True if to set the version in the copied settings file. + * @param {boolean} expectedUseDBValue + * The value expected for the `useSavedOrder` metadata attribute. + */ +async function checkLoadSettingProperties( + settingsFile, + setVersion, + expectedUseDBValue +) { + info("init search service"); + + await loadSettingsFile(settingsFile, setVersion); + + const settingsFileWritten = promiseAfterSettings(); + let ss = new SearchService(); + let result = await ss.init(); + + info("init'd search service"); + Assert.ok(Components.isSuccessCode(result)); + + await settingsFileWritten; + + let engines = await ss.getEngines(); + + Assert.equal( + engines[0].name, + "engine1", + "Should have loaded the correct first engine" + ); + Assert.equal(engines[0].alias, "testAlias", "Should have set the alias"); + Assert.equal(engines[0].hidden, false, "Should have not hidden the engine"); + Assert.equal( + engines[1].name, + "engine2", + "Should have loaded the correct second engine" + ); + Assert.equal(engines[1].alias, null, "Should have not set the alias"); + Assert.equal(engines[1].hidden, true, "Should have hidden the engine"); + + // The extra engine is the second in the list. + isSubObjectOf(EXPECTED_ENGINE.engine, engines[2]); + + let engineFromSS = ss.getEngineByName(EXPECTED_ENGINE.engine.name); + Assert.ok(!!engineFromSS); + isSubObjectOf(EXPECTED_ENGINE.engine, engineFromSS); + + Assert.equal( + engineFromSS.getSubmission("foo").uri.spec, + "http://www.google.com/search?q=foo", + "Should have the correct URL with no mozparams" + ); + + Assert.equal( + ss._settings.getAttribute("useSavedOrder"), + expectedUseDBValue, + "Should have set the useSavedOrder metadata correctly." + ); + + removeSettingsFile(); + ss._removeObservers(); +} + +add_task(async function test_legacy_setting_engine_properties() { + Services.prefs.setBoolPref(legacyUseSavedOrderPrefName, true); + + await checkLoadSettingProperties("data/search-legacy.json", false, true); + + Assert.ok( + !Services.prefs.prefHasUserValue(legacyUseSavedOrderPrefName), + "Should have cleared the legacy pref." + ); +}); + +add_task(async function test_current_setting_engine_properties() { + await checkLoadSettingProperties("data/search.json", true, false); +}); + +/** + * Test that the JSON settings written in the profile is correct. + */ +add_task(async function test_settings_write() { + info("test settings writing"); + + await loadSettingsFile("data/search.json"); + + const settingsFileWritten = promiseAfterSettings(); + await Services.search.init(); + await settingsFileWritten; + removeSettingsFile(); + + let settings = do_get_profile().clone(); + settings.append(SETTINGS_FILENAME); + Assert.ok(!settings.exists()); + + info("Next step is forcing flush"); + // Note: the dispatch is needed, to avoid some reentrency + // issues in SearchService. + let settingsWritePromise = promiseAfterSettings(); + + Services.tm.dispatchToMainThread(() => { + // Call the observe method directly to simulate a remove but not actually + // remove anything. + Services.search.wrappedJSObject._settings + .QueryInterface(Ci.nsIObserver) + .observe(null, "browser-search-engine-modified", "engine-removed"); + }); + + await settingsWritePromise; + + info("Settings write complete"); + Assert.ok(settings.exists()); + // Check that the search.json.mozlz4 settings matches the template + + info("Check search.json.mozlz4"); + let settingsData = await promiseSettingsData(); + + // Remove buildID and locale, as they are no longer used. + delete settingsTemplate.buildID; + delete settingsTemplate.locale; + + for (let engine of settingsTemplate.engines) { + // Remove _shortName from the settings template, as it is no longer supported, + // but older settings used to have it, so we keep it in the template as an + // example. + if ("_shortName" in engine) { + delete engine._shortName; + } + if ("_urls" in engine) { + // Only app-provided engines support purpose & mozparams, others do not, + // so filter them out of the expected template. + for (let urls of engine._urls) { + urls.params = urls.params.filter(p => !p.purpose && !p.mozparam); + // resultDomain is also no longer supported. + if ("resultDomain" in urls) { + delete urls.resultDomain; + } + } + } + // Remove queryCharset, if it is the same as the default, as we don't save + // it in that case. + if (engine?.queryCharset == SearchUtils.DEFAULT_QUERY_CHARSET) { + delete engine.queryCharset; + } + } + + // Note: the file is copied with an old version number, which should have + // been updated on write. + settingsTemplate.version = SearchUtils.SETTINGS_VERSION; + + isSubObjectOf(settingsTemplate, settingsData, (prop, value) => { + if (prop != "_iconURL" && prop != "{}") { + return false; + } + // Skip items that are to do with icons for extensions, as we can't + // control the uuid. + return value.startsWith("moz-extension://"); + }); +}); + +async function settings_write_check(disableFn) { + let ss = Services.search.wrappedJSObject; + + sinon.stub(ss._settings, "_write").returns(Promise.resolve()); + + // Simulate the search service being initialized. + disableFn(true); + + ss._settings.setAttribute("value", "test"); + + Assert.ok( + ss._settings._write.notCalled, + "Should not have attempted to _write" + ); + + // Wait for two periods of the normal delay to ensure we still do not write. + await new Promise(r => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(r, SearchSettings.SETTNGS_INVALIDATION_DELAY * 2) + ); + + Assert.ok( + ss._settings._write.notCalled, + "Should not have attempted to _write" + ); + + disableFn(false); + + await TestUtils.waitForCondition( + () => ss._settings._write.calledOnce, + "Should attempt to write the settings." + ); + + sinon.restore(); +} + +add_task(async function test_settings_write_prevented_during_init() { + await settings_write_check( + disable => (Services.search.wrappedJSObject._initialized = !disable) + ); +}); + +add_task(async function test_settings_write_prevented_during_reload() { + await settings_write_check( + disable => (Services.search.wrappedJSObject._reloadingEngines = disable) + ); +}); + +var EXPECTED_ENGINE = { + engine: { + name: "Test search engine", + alias: null, + description: "A test search engine (based on Google search)", + searchForm: "http://www.google.com/", + wrappedJSObject: { + _extensionID: "test-addon-id@mozilla.org", + _iconURL: + "data:image/png;base64,AAABAAEAEBAAAAEAGABoAwAAFgAAACgAAAAQAAA" + + "AIAAAAAEAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADs9Pt8xetPtu9F" + + "sfFNtu%2BTzvb2%2B%2Fne4dFJeBw0egA%2FfAJAfAA8ewBBegAAAAD%2B%2F" + + "Ptft98Mp%2BwWsfAVsvEbs%2FQeqvF8xO7%2F%2F%2F63yqkxdgM7gwE%2Fgg" + + "M%2BfQA%2BegBDeQDe7PIbotgQufcMufEPtfIPsvAbs%2FQvq%2Bfz%2Bf%2F" + + "%2B%2B%2FZKhR05hgBBhQI8hgBAgAI9ewD0%2B%2Fg3pswAtO8Cxf4Kw%2FsJ" + + "vvYAqupKsNv%2B%2Fv7%2F%2FP5VkSU0iQA7jQA9hgBDgQU%2BfQH%2F%2Ff%" + + "2FQ6fM4sM4KsN8AteMCruIqqdbZ7PH8%2Fv%2Fg6Nc%2Fhg05kAA8jAM9iQI%" + + "2BhQA%2BgQDQu6b97uv%2F%2F%2F7V8Pqw3eiWz97q8%2Ff%2F%2F%2F%2F7%" + + "2FPptpkkqjQE4kwA7kAA5iwI8iAA8hQCOSSKdXjiyflbAkG7u2s%2F%2B%2F%" + + "2F39%2F%2F7r8utrqEYtjQE8lgA7kwA7kwA9jwA9igA9hACiWSekVRyeSgiYS" + + "BHx6N%2F%2B%2Fv7k7OFRmiYtlAA5lwI7lwI4lAA7kgI9jwE9iwI4iQCoVhWc" + + "TxCmb0K%2BooT8%2Fv%2F7%2F%2F%2FJ2r8fdwI1mwA3mQA3mgA8lAE8lAE4j" + + "wA9iwE%2BhwGfXifWvqz%2B%2Ff%2F58u%2Fev6Dt4tr%2B%2F%2F2ZuIUsgg" + + "A7mgM6mAM3lgA5lgA6kQE%2FkwBChwHt4dv%2F%2F%2F728ei1bCi7VAC5XQ7" + + "kz7n%2F%2F%2F6bsZkgcB03lQA9lgM7kwA2iQktZToPK4r9%2F%2F%2F9%2F%" + + "2F%2FSqYK5UwDKZAS9WALIkFn%2B%2F%2F3%2F%2BP8oKccGGcIRJrERILYFE" + + "MwAAuEAAdX%2F%2Ff7%2F%2FP%2B%2BfDvGXQLIZgLEWgLOjlf7%2F%2F%2F%" + + "2F%2F%2F9QU90EAPQAAf8DAP0AAfMAAOUDAtr%2F%2F%2F%2F7%2B%2Fu2bCT" + + "IYwDPZgDBWQDSr4P%2F%2Fv%2F%2F%2FP5GRuABAPkAA%2FwBAfkDAPAAAesA" + + "AN%2F%2F%2B%2Fz%2F%2F%2F64g1C5VwDMYwK8Yg7y5tz8%2Fv%2FV1PYKDOc" + + "AAP0DAf4AAf0AAfYEAOwAAuAAAAD%2F%2FPvi28ymXyChTATRrIb8%2F%2F3v" + + "8fk6P8MAAdUCAvoAAP0CAP0AAfYAAO4AAACAAQAAAAAAAAAAAAAAAAAAAAAAA" + + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAA", + _urls: [ + { + type: "application/x-suggestions+json", + method: "GET", + template: + "http://suggestqueries.google.com/complete/search?output=firefox&client=firefox" + + "&hl={moz:locale}&q={searchTerms}", + params: "", + }, + { + type: "text/html", + method: "GET", + template: "http://www.google.com/search", + params: [ + { + name: "q", + value: "{searchTerms}", + purpose: undefined, + }, + ], + }, + ], + }, + }, +}; diff --git a/toolkit/components/search/tests/xpcshell/test_settings_broken.js b/toolkit/components/search/tests/xpcshell/test_settings_broken.js new file mode 100644 index 0000000000..a0e30dbb75 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings_broken.js @@ -0,0 +1,130 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test initializing from broken search settings. This is one where the engines + * array for some reason has lost all the default engines, but retained either + * one or two, or a user-supplied engine. We don't know why this happens, but + * we have seen it (bug 1578807). + */ + +"use strict"; + +const { getAppInfo } = ChromeUtils.import( + "resource://testing-common/AppInfo.jsm" +); + +const enginesSettings = { + version: SearchUtils.SETTINGS_VERSION, + buildID: "TBD", + appVersion: "TBD", + locale: "en-US", + metaData: { + searchDefault: "Test search engine", + searchDefaultHash: "TBD", + // Intentionally in the past, but shouldn't actually matter for this test. + searchDefaultExpir: 1567694909002, + current: "", + hash: "TBD", + visibleDefaultEngines: + "engine,engine-pref,engine-rel-searchform-purpose,engine-chromeicon,engine-resourceicon,engine-reordered", + visibleDefaultEnginesHash: "TBD", + }, + engines: [ + // This is a user-installed engine - the only one that was listed due to the + // original issue. + { + _name: "A second test engine", + _shortName: "engine2", + _loadPath: "[profile]/searchplugins/engine2.xml", + description: "A second test search engine (based on DuckDuckGo)", + _iconURL: + "data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAEAgQAhIOEAMjHyABIR0gA6ejpAGlqaQCpqKkAKCgoAPz9/AAZGBkAmJiYANjZ2ABXWFcAent6ALm6uQA8OjwAiIiIiIiIiIiIiI4oiL6IiIiIgzuIV4iIiIhndo53KIiIiB/WvXoYiIiIfEZfWBSIiIEGi/foqoiIgzuL84i9iIjpGIoMiEHoiMkos3FojmiLlUipYliEWIF+iDe0GoRa7D6GPbjcu1yIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + _iconMapObj: { + '{"width":16,"height":16}': + "data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAEAgQAhIOEAMjHyABIR0gA6ejpAGlqaQCpqKkAKCgoAPz9/AAZGBkAmJiYANjZ2ABXWFcAent6ALm6uQA8OjwAiIiIiIiIiIiIiI4oiL6IiIiIgzuIV4iIiIhndo53KIiIiB/WvXoYiIiIfEZfWBSIiIEGi/foqoiIgzuL84i9iIjpGIoMiEHoiMkos3FojmiLlUipYliEWIF+iDe0GoRa7D6GPbjcu1yIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + _isBuiltin: false, + _metaData: { + order: 1, + }, + _urls: [ + { + template: "https://duckduckgo.com/?q={searchTerms}", + rels: [], + resultDomain: "duckduckgo.com", + params: [], + }, + ], + queryCharset: "UTF-8", + filePath: "TBD", + }, + ], +}; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + // Allow telemetry probes which may otherwise be disabled for some applications (e.g. Thunderbird) + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + await SearchTestUtils.useTestEngines(); + Services.prefs.setCharPref(SearchUtils.BROWSER_SEARCH_PREF + "region", "US"); + Services.locale.availableLocales = ["en-US"]; + Services.locale.requestedLocales = ["en-US"]; + + // We dynamically generate the hashes because these depend on the profile. + enginesSettings.metaData.searchDefaultHash = SearchUtils.getVerificationHash( + enginesSettings.metaData.searchDefault + ); + enginesSettings.metaData.hash = SearchUtils.getVerificationHash( + enginesSettings.metaData.current + ); + enginesSettings.metaData.visibleDefaultEnginesHash = SearchUtils.getVerificationHash( + enginesSettings.metaData.visibleDefaultEngines + ); + const appInfo = getAppInfo(); + enginesSettings.buildID = appInfo.platformBuildID; + enginesSettings.appVersion = appInfo.version; + + await OS.File.writeAtomic( + OS.Path.join(OS.Constants.Path.profileDir, SETTINGS_FILENAME), + new TextEncoder().encode(JSON.stringify(enginesSettings)), + { compression: "lz4" } + ); +}); + +add_task(async function test_cached_engine_properties() { + info("init search service"); + + const initResult = await Services.search.init(); + + info("init'd search service"); + Assert.ok( + Components.isSuccessCode(initResult), + "Should have successfully created the search service" + ); + + const engines = await Services.search.getEngines(); + + const expectedEngines = [ + // Default engines + "Test search engine", + // Rest of engines in order + "engine-resourceicon", + "engine-chromeicon", + "engine-pref", + "engine-rel-searchform-purpose", + "Test search engine (Reordered)", + "A second test engine", + ]; + + Assert.deepEqual( + engines.map(e => e.name), + expectedEngines, + "Should have the expected default engines" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_settings_duplicate.js b/toolkit/components/search/tests/xpcshell/test_settings_duplicate.js new file mode 100644 index 0000000000..56ac4abf5c --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings_duplicate.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test initializing with an engine that's a duplicate of an app-provided + * engine. + */ + +"use strict"; + +const { getAppInfo } = ChromeUtils.import( + "resource://testing-common/AppInfo.jsm" +); + +const enginesSettings = { + version: SearchUtils.SETTINGS_VERSION, + buildID: "TBD", + appVersion: "TBD", + locale: "en-US", + metaData: { + searchDefault: "Test search engine", + searchDefaultHash: "TBD", + // Intentionally in the past, but shouldn't actually matter for this test. + searchDefaultExpir: 1567694909002, + current: "", + hash: "TBD", + visibleDefaultEngines: + "engine,engine-pref,engine-rel-searchform-purpose,engine-chromeicon,engine-resourceicon,engine-reordered", + visibleDefaultEnginesHash: "TBD", + }, + engines: [ + { + _metaData: { alias: null }, + _isAppProvided: true, + _name: "engine1", + }, + { + _metaData: { alias: null }, + _isAppProvided: true, + _name: "engine2", + }, + // This is a user-installed engine - the only one that was listed due to the + // original issue. + { + _name: "engine1", + _shortName: "engine1", + _loadPath: "[test]oldduplicateversion", + description: "An old near duplicate version of engine1", + _iconURL: + "data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAEAgQAhIOEAMjHyABIR0gA6ejpAGlqaQCpqKkAKCgoAPz9/AAZGBkAmJiYANjZ2ABXWFcAent6ALm6uQA8OjwAiIiIiIiIiIiIiI4oiL6IiIiIgzuIV4iIiIhndo53KIiIiB/WvXoYiIiIfEZfWBSIiIEGi/foqoiIgzuL84i9iIjpGIoMiEHoiMkos3FojmiLlUipYliEWIF+iDe0GoRa7D6GPbjcu1yIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + _iconMapObj: { + '{"width":16,"height":16}': + "data:image/x-icon;base64,AAABAAEAEBAQAAEABAAoAQAAFgAAACgAAAAQAAAAIAAAAAEABAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAEAgQAhIOEAMjHyABIR0gA6ejpAGlqaQCpqKkAKCgoAPz9/AAZGBkAmJiYANjZ2ABXWFcAent6ALm6uQA8OjwAiIiIiIiIiIiIiI4oiL6IiIiIgzuIV4iIiIhndo53KIiIiB/WvXoYiIiIfEZfWBSIiIEGi/foqoiIgzuL84i9iIjpGIoMiEHoiMkos3FojmiLlUipYliEWIF+iDe0GoRa7D6GPbjcu1yIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + _metaData: { + order: 1, + }, + _urls: [ + { + template: "https://example.com/?myquery={searchTerms}", + rels: [], + resultDomain: "example.com", + params: [], + }, + ], + queryCharset: "UTF-8", + filePath: "TBD", + }, + ], +}; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + // Allow telemetry probes which may otherwise be disabled for some applications (e.g. Thunderbird) + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + await SearchTestUtils.useTestEngines("data1"); + Services.prefs.setCharPref(SearchUtils.BROWSER_SEARCH_PREF + "region", "US"); + Services.locale.availableLocales = ["en-US"]; + Services.locale.requestedLocales = ["en-US"]; + + // We dynamically generate the hashes because these depend on the profile. + enginesSettings.metaData.searchDefaultHash = SearchUtils.getVerificationHash( + enginesSettings.metaData.searchDefault + ); + enginesSettings.metaData.hash = SearchUtils.getVerificationHash( + enginesSettings.metaData.current + ); + enginesSettings.metaData.visibleDefaultEnginesHash = SearchUtils.getVerificationHash( + enginesSettings.metaData.visibleDefaultEngines + ); + let appInfo = getAppInfo(); + enginesSettings.buildID = appInfo.platformBuildID; + enginesSettings.appVersion = appInfo.version; + + await OS.File.writeAtomic( + OS.Path.join(OS.Constants.Path.profileDir, SETTINGS_FILENAME), + new TextEncoder().encode(JSON.stringify(enginesSettings)), + { compression: "lz4" } + ); +}); + +add_task(async function test_cached_duplicate() { + info("init search service"); + + let initResult = await Services.search.init(); + + info("init'd search service"); + Assert.ok( + Components.isSuccessCode(initResult), + "Should have successfully created the search service" + ); + + let engine = await Services.search.getEngineByName("engine1"); + let submission = engine.getSubmission("foo"); + Assert.equal( + submission.uri.spec, + "https://1.example.com/search?q=foo", + "Should have not changed the app provided engine." + ); + + let engines = await Services.search.getEngines(); + + Assert.deepEqual( + engines.map(e => e.name), + ["engine1", "engine2"], + "Should have the expected default engines" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_settings_good.js b/toolkit/components/search/tests/xpcshell/test_settings_good.js new file mode 100644 index 0000000000..f7275a7c73 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings_good.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test initializing from good search settings. + */ + +"use strict"; + +const { getAppInfo } = ChromeUtils.import( + "resource://testing-common/AppInfo.jsm" +); + +const enginesSettings = { + version: SearchUtils.SETTINGS_VERSION, + buildID: "TBD", + appVersion: "TBD", + locale: "en-US", + metaData: { + searchDefault: "Test search engine", + searchDefaultHash: "TBD", + // Intentionally in the past, but shouldn't actually matter for this test. + searchDefaultExpir: 1567694909002, + current: "", + hash: "TBD", + visibleDefaultEngines: "engine1,engine2", + visibleDefaultEnginesHash: "TBD", + }, + engines: [ + { + _metaData: { alias: null }, + _isAppProvided: true, + _name: "engine1", + }, + { + _metaData: { alias: null }, + _isAppProvided: true, + _name: "engine2", + }, + ], +}; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + // Allow telemetry probes which may otherwise be disabled for some applications (e.g. Thunderbird) + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + await SearchTestUtils.useTestEngines("data1"); + Services.prefs.setCharPref(SearchUtils.BROWSER_SEARCH_PREF + "region", "US"); + Services.locale.availableLocales = ["en-US"]; + Services.locale.requestedLocales = ["en-US"]; + + // We dynamically generate the hashes because these depend on the profile. + enginesSettings.metaData.searchDefaultHash = SearchUtils.getVerificationHash( + enginesSettings.metaData.searchDefault + ); + enginesSettings.metaData.hash = SearchUtils.getVerificationHash( + enginesSettings.metaData.current + ); + enginesSettings.metaData.visibleDefaultEnginesHash = SearchUtils.getVerificationHash( + enginesSettings.metaData.visibleDefaultEngines + ); + const appInfo = getAppInfo(); + enginesSettings.buildID = appInfo.platformBuildID; + enginesSettings.appVersion = appInfo.version; + + await OS.File.writeAtomic( + OS.Path.join(OS.Constants.Path.profileDir, SETTINGS_FILENAME), + new TextEncoder().encode(JSON.stringify(enginesSettings)), + { compression: "lz4" } + ); +}); + +add_task(async function test_cached_engine_properties() { + info("init search service"); + + const initResult = await Services.search.init(); + + info("init'd search service"); + Assert.ok( + Components.isSuccessCode(initResult), + "Should have successfully created the search service" + ); + + const engines = await Services.search.getEngines(); + + Assert.deepEqual( + engines.map(e => e.name), + ["engine1", "engine2"], + "Should have the expected default engines" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_settings_ignorelist.js b/toolkit/components/search/tests/xpcshell/test_settings_ignorelist.js new file mode 100644 index 0000000000..89c5fd33e1 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings_ignorelist.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test initializing from the search settings. + */ + +"use strict"; + +var { getAppInfo } = ChromeUtils.import( + "resource://testing-common/AppInfo.jsm" +); + +var settingsTemplate; + +/** + * Test reading from search.json.mozlz4 + */ +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + await setupRemoteSettings(); + + settingsTemplate = await readJSONFile( + do_get_file("data/search_ignorelist.json") + ); + settingsTemplate.buildID = getAppInfo().platformBuildID; + + await promiseSaveSettingsData(settingsTemplate); +}); + +/** + * Start the search service and confirm the settings were reset + */ +add_task(async function test_settings_rest() { + info("init search service"); + + let updatePromise = SearchTestUtils.promiseSearchNotification( + "settings-update-complete" + ); + + let result = await Services.search.init(); + + Assert.ok( + Components.isSuccessCode(result), + "Search service should be successfully initialized" + ); + await updatePromise; + + const engines = await Services.search.getEngines(); + + // Engine list will have been reset to the default, + // Not the one engine in the settings. + // It should have more than one engine. + Assert.greater( + engines.length, + 1, + "Should have more than one engine in the list" + ); + + removeSettingsFile(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_settings_none.js b/toolkit/components/search/tests/xpcshell/test_settings_none.js new file mode 100644 index 0000000000..eaf5fff1ff --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings_none.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * test_nosettings: Start search engine + * - without search.json.mozlz4 + * + * Ensure that : + * - nothing explodes; + * - search.json.mozlz4 is created. + */ + +add_task(async function setup() { + useHttpServer(); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_nosettings() { + let search = Services.search; + + let afterSettingsPromise = promiseAfterSettings(); + + await search.init(); + + // Check that the settings is created at startup + await afterSettingsPromise; + + // Check that search.json.mozlz4 has been created. + let settingsFile = do_get_profile().clone(); + settingsFile.append(SETTINGS_FILENAME); + Assert.ok(settingsFile.exists()); + + // Add engine and wait for settings update + await addTestEngines([ + { name: "Test search engine", xmlFileName: "engine.xml" }, + ]); + + info("Engine has been added, let's wait for the settings to be built"); + await promiseAfterSettings(); + + info("Searching test engine in settings"); + let settings = await promiseSettingsData(); + let found = false; + for (let engine of settings.engines) { + if (engine._name == "Test search engine") { + found = true; + break; + } + } + Assert.ok(found); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_settings_obsolete.js b/toolkit/components/search/tests/xpcshell/test_settings_obsolete.js new file mode 100644 index 0000000000..89cc54111d --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings_obsolete.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test removing obsolete engine types on upgrade of settings. + */ + +"use strict"; + +const { getAppInfo } = ChromeUtils.import( + "resource://testing-common/AppInfo.jsm" +); + +async function loadSettingsFile(settingsFile, name) { + let settings = await readJSONFile(do_get_file(settingsFile)); + + settings.metaData.current = name; + settings.metaData.hash = SearchUtils.getVerificationHash(name); + + await promiseSaveSettingsData(settings); +} + +/** + * Start the search service and confirm the engine properties match the expected values. + * + * @param {string} settingsFile + * The path to the settings file to use. + * @param {string} engineName + * The engine name that should be default and is being removed. + */ +async function checkLoadSettingProperties(settingsFile, engineName) { + await loadSettingsFile(settingsFile, engineName); + + const settingsFileWritten = promiseAfterSettings(); + let ss = new SearchService(); + let result = await ss.init(); + + Assert.ok( + Components.isSuccessCode(result), + "Should have successfully initialized the search service" + ); + + await settingsFileWritten; + + let engines = await ss.getEngines(); + + Assert.deepEqual( + engines.map(e => e.name), + ["engine1", "engine2"], + "Should have only loaded the app-provided engines" + ); + + Assert.equal( + (await Services.search.getDefault()).name, + "engine1", + "Should have used the configured default engine" + ); + + removeSettingsFile(); + ss._removeObservers(); +} + +/** + * Test reading from search.json.mozlz4 + */ +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data1"); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_obsolete_distribution_engine() { + await checkLoadSettingProperties( + "data/search-obsolete-distribution.json", + "Distribution" + ); +}); + +add_task(async function test_obsolete_langpack_engine() { + await checkLoadSettingProperties( + "data/search-obsolete-langpack.json", + "Langpack" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_settings_persist.js b/toolkit/components/search/tests/xpcshell/test_settings_persist.js new file mode 100644 index 0000000000..6cdb92d3ff --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_settings_persist.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +SearchTestUtils.initXPCShellAddonManager(this, "system"); + +const CONFIG_DEFAULT = [ + { + webExtension: { id: "plainengine@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + }, + { + webExtension: { id: "special-engine@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + }, +]; + +const CONFIG_UPDATED = [ + { + webExtension: { id: "plainengine@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + }, +]; + +async function startup() { + let settingsFileWritten = promiseAfterSettings(); + let ss = new SearchService(); + await AddonTestUtils.promiseRestartManager(); + await ss.init(false); + await settingsFileWritten; + return ss; +} + +async function updateConfig(config) { + const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY); + settings.get.restore(); + sinon.stub(settings, "get").returns(config); +} + +async function visibleEngines(ss) { + return (await ss.getVisibleEngines()).map(e => e._name); +} + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("test-extensions", null, CONFIG_DEFAULT); + registerCleanupFunction(AddonTestUtils.promiseShutdownManager); + await AddonTestUtils.promiseStartupManager(); + // This is only needed as otherwise events will not be properly notified + // due to https://searchfox.org/mozilla-central/source/toolkit/components/search/SearchUtils.jsm#186 + await Services.search.init(false); + Services.search.wrappedJSObject._removeObservers(); +}); + +add_task(async function() { + let ss = await startup(); + Assert.ok( + (await visibleEngines(ss)).includes("Special"), + "Should have both engines on first startup" + ); + + let settingsFileWritten = promiseAfterSettings(); + let engine = await ss.getEngineByName("Special"); + await ss.removeEngine(engine); + await settingsFileWritten; + + Assert.ok( + !(await visibleEngines(ss)).includes("Special"), + "Special has been remove, only Plain should remain" + ); + + ss._removeObservers(); + updateConfig(CONFIG_UPDATED); + ss = await startup(); + + Assert.ok( + !(await visibleEngines(ss)).includes("Special"), + "Updated to new configuration that doesnt have Special" + ); + + ss._removeObservers(); + updateConfig(CONFIG_DEFAULT); + ss = await startup(); + + Assert.ok( + !(await visibleEngines(ss)).includes("Special"), + "Configuration now includes Special but we should remember its removal" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_sort_orders-no-hints.js b/toolkit/components/search/tests/xpcshell/test_sort_orders-no-hints.js new file mode 100644 index 0000000000..2b167a95bf --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_sort_orders-no-hints.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check the correct default engines are picked from the configuration list, + * when we have some with the same orderHint, and some without any. + */ + +"use strict"; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + await SearchTestUtils.useTestEngines( + "data", + null, + (await readJSONFile(do_get_file("data/engines-no-order-hint.json"))).data + ); + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); +}); + +async function checkOrder(type, expectedOrder) { + // Reset the sorted list. + Services.search.wrappedJSObject.__sortedEngines = null; + + const sortedEngines = await Services.search[type](); + Assert.deepEqual( + sortedEngines.map(s => s.name), + expectedOrder, + `Should have the expected engine order from ${type}` + ); +} + +add_task(async function test_engine_sort_with_non_builtins_sort() { + await Services.search.addEngineWithDetails("nonbuiltin1", { + method: "get", + template: "http://example.com/?search={searchTerms}", + }); + + // As we've added an engine, the pref will have been set to true, but + // we do really want to test the default sort. + Services.search.wrappedJSObject._settings.setAttribute( + "useSavedOrder", + false + ); + + const EXPECTED_ORDER = [ + // Default engine. + "Test search engine", + // Alphabetical order for the two with orderHint = 1000. + "engine-chromeicon", + "engine-rel-searchform-purpose", + // Alphabetical order for the remaining engines without orderHint. + "engine-pref", + "engine-resourceicon", + "Test search engine (Reordered)", + ]; + + // We should still have the same built-in engines listed. + await checkOrder("getAppProvidedEngines", EXPECTED_ORDER); + + const expected = [...EXPECTED_ORDER]; + // This is inserted in alphabetical order for the last three. + expected.splice(expected.length - 1, 0, "nonbuiltin1"); + await checkOrder("getEngines", expected); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_sort_orders.js b/toolkit/components/search/tests/xpcshell/test_sort_orders.js new file mode 100644 index 0000000000..4c2974e677 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_sort_orders.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check the correct default engines are picked from the configuration list, + * and have the correct orders. + */ + +"use strict"; + +const SEARCH_PREF = SearchUtils.BROWSER_SEARCH_PREF; + +const EXPECTED_ORDER = [ + // Default engines + "Test search engine", + "engine-pref", + // Now the engines in orderHint order. + "engine-resourceicon", + "engine-chromeicon", + "engine-rel-searchform-purpose", + "Test search engine (Reordered)", +]; + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + await SearchTestUtils.useTestEngines(); + + Services.locale.availableLocales = [ + ...Services.locale.availableLocales, + "gd", + ]; + + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled", + true + ); +}); + +async function checkOrder(type, expectedOrder) { + // Reset the sorted list. + Services.search.wrappedJSObject.__sortedEngines = null; + + const sortedEngines = await Services.search[type](); + Assert.deepEqual( + sortedEngines.map(s => s.name), + expectedOrder, + `Should have the expected engine order from ${type}` + ); +} + +add_task(async function test_engine_sort_only_builtins() { + await checkOrder("getAppProvidedEngines", EXPECTED_ORDER); + await checkOrder("getEngines", EXPECTED_ORDER); +}); + +add_task(async function test_engine_sort_with_non_builtins_sort() { + await Services.search.addEngineWithDetails("nonbuiltin1", { + method: "get", + template: "http://example.com/?search={searchTerms}", + }); + + // As we've added an engine, the pref will have been set to true, but + // we do really want to test the default sort. + Services.search.wrappedJSObject._settings.setAttribute( + "useSavedOrder", + false + ); + + // We should still have the same built-in engines listed. + await checkOrder("getAppProvidedEngines", EXPECTED_ORDER); + + const expected = [...EXPECTED_ORDER]; + expected.splice(EXPECTED_ORDER.length, 0, "nonbuiltin1"); + await checkOrder("getEngines", expected); +}); + +add_task(async function test_engine_sort_with_locale() { + await promiseSetLocale("gd"); + + const expected = [ + "engine-resourceicon-gd", + "engine-pref", + "engine-rel-searchform-purpose", + "engine-chromeicon", + "Test search engine (Reordered)", + ]; + + await checkOrder("getAppProvidedEngines", expected); + expected.push("nonbuiltin1"); + await checkOrder("getEngines", expected); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_validate_engines.js b/toolkit/components/search/tests/xpcshell/test_validate_engines.js new file mode 100644 index 0000000000..9049a3a169 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_validate_engines.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Ensure all the engines defined in the configuration are valid by +// creating a refined configuration that includes all the engines everywhere. + +"use strict"; + +const { SearchService } = ChromeUtils.import( + "resource://gre/modules/SearchService.jsm" +); + +const ss = new SearchService(); + +add_task(async function test_validate_engines() { + let settings = RemoteSettings(SearchUtils.SETTINGS_KEY); + let config = await settings.get(); + config = config.map(e => { + return { + appliesTo: [ + { + included: { + everywhere: true, + }, + }, + ], + webExtension: { + id: e.webExtension.id, + }, + }; + }); + + sinon.stub(settings, "get").returns(config); + await AddonTestUtils.promiseStartupManager(); + await ss.init(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_validate_manifests.js b/toolkit/components/search/tests/xpcshell/test_validate_manifests.js new file mode 100644 index 0000000000..3906174ab4 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_validate_manifests.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.importGlobalProperties(["fetch"]); + +const { ExtensionData } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" +); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +const SEARCH_EXTENSIONS_PATH = "resource://search-extensions"; + +function getFileURI(resourceURI) { + let resHandler = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + let filePath = resHandler.resolveURI(Services.io.newURI(resourceURI)); + return Services.io.newURI(filePath); +} + +async function getSearchExtensions() { + // Fetching the root will give us the directory listing which we can parse + // for each file name + let list = await fetch(`${SEARCH_EXTENSIONS_PATH}/`).then(req => req.text()); + return list + .split("\n") + .slice(2) + .reduce((acc, line) => { + let parts = line.split(" "); + if (parts.length > 2 && !parts[1].endsWith(".json")) { + // When the directory listing comes from omni jar each engine + // has a trailing slash (engine/) which we dont get locally, or want. + acc.push(parts[1].split("/")[0]); + } + return acc; + }, []); +} + +add_task(async function test_validate_manifest() { + let searchExtensions = await getSearchExtensions(); + ok( + !!searchExtensions.length, + `Found ${searchExtensions.length} search extensions` + ); + for (const xpi of searchExtensions) { + info(`loading: ${SEARCH_EXTENSIONS_PATH}/${xpi}/`); + let fileURI = getFileURI(`${SEARCH_EXTENSIONS_PATH}/${xpi}/`); + let extension = new ExtensionData(fileURI); + await extension.loadManifest(); + let locales = await extension.promiseLocales(); + for (let locale of locales.keys()) { + try { + let manifest = await extension.getLocalizedManifest(locale); + ok(!!manifest, `parsed manifest ${xpi.leafName} in ${locale}`); + } catch (e) { + ok( + false, + `FAIL manifest for ${xpi.leafName} in locale ${locale} failed ${e} :: ${e.stack}` + ); + } + } + } +}); diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_builtin_upgrade.js b/toolkit/components/search/tests/xpcshell/test_webextensions_builtin_upgrade.js new file mode 100644 index 0000000000..97b705ef89 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_webextensions_builtin_upgrade.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Enable SCOPE_APPLICATION for builtin testing. Default in tests is only SCOPE_PROFILE. +// AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION == 5; +Services.prefs.setIntPref("extensions.enabledScopes", 5); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +SearchTestUtils.initXPCShellAddonManager(this); + +const TEST_CONFIG = [ + { + webExtension: { + id: "multilocale@search.mozilla.org", + locales: ["af", "an"], + }, + appliesTo: [{ included: { everywhere: true } }], + }, + { + webExtension: { + id: "plainengine@search.mozilla.org", + }, + appliesTo: [{ included: { everywhere: true } }], + params: { + searchUrlGetParams: [ + { + name: "config", + value: "applied", + }, + ], + }, + }, +]; + +async function getEngineNames() { + let engines = await Services.search.getEngines(); + return engines.map(engine => engine._name); +} + +function makePlainExtension(version) { + return { + useAddonManager: "permanent", + manifest: { + name: "Plain", + version, + applications: { + gecko: { + id: "plainengine@search.mozilla.org", + }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Plain", + search_url: "https://duckduckgo.com/", + params: [ + { + name: "q", + value: "{searchTerms}", + }, + { + name: "t", + condition: "purpose", + purpose: "contextmenu", + value: "ffcm", + }, + { + name: "t", + condition: "purpose", + purpose: "keyword", + value: "ffab", + }, + { + name: "t", + condition: "purpose", + purpose: "searchbar", + value: "ffsb", + }, + { + name: "t", + condition: "purpose", + purpose: "homepage", + value: "ffhp", + }, + { + name: "t", + condition: "purpose", + purpose: "newtab", + value: "ffnt", + }, + ], + suggest_url: "https://ac.duckduckgo.com/ac/q={searchTerms}&type=list", + }, + }, + }, + }; +} + +function makeMultiLocaleExtension(version) { + return { + useAddonManager: "permanent", + manifest: { + name: "__MSG_searchName__", + version, + applications: { + gecko: { + id: "multilocale@search.mozilla.org", + }, + }, + default_locale: "an", + chrome_settings_overrides: { + search_provider: { + name: "__MSG_searchName__", + search_url: "__MSG_searchUrl__", + }, + }, + }, + files: { + "_locales/af/messages.json": { + searchUrl: { + message: `https://example.af/?q={searchTerms}&version=${version}`, + description: "foo", + }, + searchName: { + message: `Multilocale AF`, + description: "foo", + }, + }, + "_locales/an/messages.json": { + searchUrl: { + message: `https://example.an/?q={searchTerms}&version=${version}`, + description: "foo", + }, + searchName: { + message: `Multilocale AN`, + description: "foo", + }, + }, + }, + }; +} + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("test-extensions", null, TEST_CONFIG); + await promiseStartupManager(); + + registerCleanupFunction(promiseShutdownManager); + await Services.search.init(); +}); + +add_task(async function basic_multilocale_test() { + Assert.deepEqual(await getEngineNames(), [ + "Multilocale AF", + "Multilocale AN", + "Plain", + ]); + + let ext = ExtensionTestUtils.loadExtension(makeMultiLocaleExtension("2.0")); + await ext.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext); + + Assert.deepEqual(await getEngineNames(), [ + "Multilocale AF", + "Multilocale AN", + "Plain", + ]); + + let engine = await Services.search.getEngineByName("Multilocale AF"); + Assert.equal( + engine.getSubmission("test").uri.spec, + "https://example.af/?q=test&version=2.0", + "Engine got update" + ); + engine = await Services.search.getEngineByName("Multilocale AN"); + Assert.equal( + engine.getSubmission("test").uri.spec, + "https://example.an/?q=test&version=2.0", + "Engine got update" + ); + + await ext.unload(); +}); + +add_task(async function upgrade_with_configuration_change_test() { + Assert.deepEqual(await getEngineNames(), [ + "Multilocale AF", + "Multilocale AN", + "Plain", + ]); + + let engine = await Services.search.getEngineByName("Plain"); + Assert.equal( + engine.getSubmission("test").uri.spec, + // This test engine specifies the q and t params in its search_url, therefore + // we get both those and the extra parameter specified in the test config. + "https://duckduckgo.com/?q=test&t=ffsb&config=applied", + "Should have the configuration applied before update." + ); + + let ext = ExtensionTestUtils.loadExtension(makePlainExtension("2.0")); + await ext.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext); + + Assert.deepEqual(await getEngineNames(), [ + "Multilocale AF", + "Multilocale AN", + "Plain", + ]); + + engine = await Services.search.getEngineByName("Plain"); + Assert.equal( + engine.getSubmission("test").uri.spec, + // This test engine specifies the q and t params in its search_url, therefore + // we get both those and the extra parameter specified in the test config. + "https://duckduckgo.com/?q=test&t=ffsb&config=applied", + "Should still have the configuration applied after update." + ); + + await ext.unload(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_install.js b/toolkit/components/search/tests/xpcshell/test_webextensions_install.js new file mode 100644 index 0000000000..bcb0593e17 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_webextensions_install.js @@ -0,0 +1,125 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + ExtensionTestUtils: "resource://testing-common/ExtensionXPCShellUtils.jsm", +}); + +const { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +SearchTestUtils.initXPCShellAddonManager(this); + +async function getEngineNames() { + let engines = await Services.search.getEngines(); + return engines.map(engine => engine._name); +} + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("test-extensions"); + await promiseStartupManager(); + + Services.locale.availableLocales = [ + ...Services.locale.availableLocales, + "af", + ]; + + registerCleanupFunction(async () => { + await promiseShutdownManager(); + Services.prefs.clearUserPref("browser.search.region"); + }); +}); + +add_task(async function basic_install_test() { + await Services.search.init(); + await promiseAfterSettings(); + + // On first boot, we get the configuration defaults + Assert.deepEqual(await getEngineNames(), ["Plain", "Special"]); + + // User installs a new search engine + let extension = await SearchTestUtils.installSearchExtension({ + encoding: "windows-1252", + }); + Assert.deepEqual((await getEngineNames()).sort(), [ + "Example", + "Plain", + "Special", + ]); + + let engine = await Services.search.getEngineByName("Example"); + Assert.equal( + engine.wrappedJSObject.queryCharset, + "windows-1252", + "Should have the correct charset" + ); + + // User uninstalls their engine + await extension.awaitStartup(); + await extension.unload(); + await promiseAfterSettings(); + Assert.deepEqual(await getEngineNames(), ["Plain", "Special"]); +}); + +add_task(async function test_install_duplicate_engine() { + let extension = await SearchTestUtils.installSearchExtension({ + name: "Plain", + search_url: "https://example.com/plain", + }); + + let engine = await Services.search.getEngineByName("Plain"); + let submission = engine.getSubmission("foo"); + Assert.equal( + submission.uri.spec, + "https://duckduckgo.com/?q=foo&t=ffsb", + "Should have not changed the app provided engine." + ); + + // User uninstalls their engine + await extension.awaitStartup(); + await extension.unload(); +}); + +add_task(async function basic_multilocale_test() { + await promiseSetHomeRegion("an"); + + Assert.deepEqual(await getEngineNames(), [ + "Plain", + "Special", + "Multilocale AN", + ]); +}); + +add_task(async function complex_multilocale_test() { + await promiseSetHomeRegion("af"); + + Assert.deepEqual(await getEngineNames(), [ + "Plain", + "Special", + "Multilocale AF", + "Multilocale AN", + ]); +}); + +add_task(async function test_manifest_selection() { + // Sets the home region without updating. + Region._setHomeRegion("an", false); + await promiseSetLocale("af"); + + let engine = await Services.search.getEngineByName("Multilocale AN"); + Assert.ok( + engine.iconURI.spec.endsWith("favicon-an.ico"), + "Should have the correct favicon for an extension of one locale using a different locale." + ); + Assert.equal( + engine.description, + "A enciclopedia Libre", + "Should have the correct engine name for an extension of one locale using a different locale." + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_normandy_upgrade.js b/toolkit/components/search/tests/xpcshell/test_webextensions_normandy_upgrade.js new file mode 100644 index 0000000000..faf57c1807 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_webextensions_normandy_upgrade.js @@ -0,0 +1,93 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +SearchTestUtils.initXPCShellAddonManager(this, "system"); + +async function restart() { + Services.search.wrappedJSObject.reset(); + await AddonTestUtils.promiseRestartManager(); + await Services.search.init(false); +} + +const CONFIG_DEFAULT = [ + { + webExtension: { id: "plainengine@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + }, +]; + +const CONFIG_UPDATED = [ + { + webExtension: { id: "plainengine@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + }, + { + webExtension: { id: "example@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + }, +]; + +async function getEngineNames() { + let engines = await Services.search.getAppProvidedEngines(); + return engines.map(engine => engine._name); +} + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("test-extensions", null, CONFIG_DEFAULT); + await AddonTestUtils.promiseStartupManager(); + registerCleanupFunction(AddonTestUtils.promiseShutdownManager); + SearchTestUtils.useMockIdleService(); + await Services.search.init(); +}); + +// Test the situation where we receive an updated configuration +// that references an engine that doesnt exist locally as it +// will be installed by Normandy. +add_task(async function test_config_before_normandy() { + // Ensure initial default setup. + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT); + await restart(); + Assert.deepEqual(await getEngineNames(), ["Plain"]); + // Updated configuration references nonexistant engine. + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_UPDATED); + Assert.deepEqual( + await getEngineNames(), + ["Plain"], + "Updated engine hasnt been installed yet" + ); + // Normandy then installs the engine. + let addon = await SearchTestUtils.installSystemSearchExtension(); + Assert.deepEqual( + await getEngineNames(), + ["Plain", "Example"], + "Both engines are now enabled" + ); + await addon.unload(); +}); + +// Test the situation where we receive a newly installed +// engine from Normandy followed by the update to the +// configuration that uses that engine. +add_task(async function test_normandy_before_config() { + // Ensure initial default setup. + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT); + await restart(); + Assert.deepEqual(await getEngineNames(), ["Plain"]); + // Normandy installs the enigne. + let addon = await SearchTestUtils.installSystemSearchExtension(); + Assert.deepEqual( + await getEngineNames(), + ["Plain"], + "Normandy engine ignored as not in config yet" + ); + // Configuration is updated to use the engine. + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_UPDATED); + Assert.deepEqual( + await getEngineNames(), + ["Plain", "Example"], + "Both engines are now enabled" + ); + await addon.unload(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_startup_remove.js b/toolkit/components/search/tests/xpcshell/test_webextensions_startup_remove.js new file mode 100644 index 0000000000..fdbb1c83ae --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_webextensions_startup_remove.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +const ENGINE_ID = "enginetest@example.com"; +let xpi; +let profile = do_get_profile().clone(); + +Services.prefs.setIntPref("extensions.autoDisableScopes", 0); +SearchTestUtils.initXPCShellAddonManager(this); + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("data1"); + xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: "1.0", + applications: { + gecko: { id: ENGINE_ID }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Test Engine", + search_url: `https://example.com/?q={searchTerms}`, + }, + }, + }, + }); + await AddonTestUtils.manuallyInstall(xpi); +}); + +add_task(async function test_removeAddonOnStartup() { + // First startup the add-on manager and ensure the engine is installed. + await AddonTestUtils.promiseStartupManager(); + let promise = promiseAfterSettings(); + await Services.search.init(); + + let engine = Services.search.getEngineByName("Test Engine"); + let allEngines = await Services.search.getEngines(); + + Assert.ok(!!engine, "Should have installed the test engine"); + + await Services.search.setDefault(engine); + await promise; + + await AddonTestUtils.promiseShutdownManager(); + + // Now remove it, reset the search service and start up the add-on manager. + // Note: the saved settings will have the engine in. If this didn't work, + // the engine would still be present. + await OS.File.remove( + OS.Path.join(profile.path, "extensions", `${ENGINE_ID}.xpi`) + ); + + let removePromise = SearchTestUtils.promiseSearchNotification( + SearchUtils.MODIFIED_TYPE.REMOVED, + SearchUtils.TOPIC_ENGINE_MODIFIED + ); + Services.search.wrappedJSObject.reset(); + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); + await removePromise; + + Assert.ok( + !Services.search.getEngineByName("Test Engine"), + "Should have removed the test engine" + ); + + let newEngines = await Services.search.getEngines(); + Assert.deepEqual( + newEngines.map(e => e.name), + allEngines.map(e => e.name).filter(n => n != "Test Engine"), + "Should no longer have the test engine in the full list" + ); + let newDefault = await Services.search.getDefault(); + Assert.equal( + newDefault.name, + "engine1", + "Should have changed the default engine back to the configuration default" + ); + + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_upgrade.js b/toolkit/components/search/tests/xpcshell/test_webextensions_upgrade.js new file mode 100644 index 0000000000..b6fe0ae5ea --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_webextensions_upgrade.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +SearchTestUtils.initXPCShellAddonManager(this); + +function extInfo(id, name, version, keyword) { + return { + useAddonManager: "permanent", + manifest: { + version, + applications: { + gecko: { id: `${id}@tests.mozilla.org` }, + }, + chrome_settings_overrides: { + search_provider: { + name, + keyword, + search_url: `https://example.com/?q={searchTerms}&version=${version}`, + }, + }, + }, + }; +} + +add_task(async function setup() { + await promiseStartupManager(); + registerCleanupFunction(promiseShutdownManager); +}); + +add_task(async function basic_install_test() { + await Services.search.init(); + await promiseAfterSettings(); + + let info = extInfo("example", "Example", "1.0", "foo"); + let extension = ExtensionTestUtils.loadExtension(info); + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + + let engine = await Services.search.getEngineByAlias("foo"); + Assert.ok(engine, "Can fetch engine with alias"); + engine.alias = "testing"; + + engine = await Services.search.getEngineByAlias("testing"); + Assert.ok(engine, "Can fetch engine by alias"); + let params = engine.getSubmission("test").uri.query.split("&"); + Assert.ok(params.includes("version=1.0"), "Correct version installed"); + + let promiseChanged = TestUtils.topicObserved( + "browser-search-engine-modified", + (eng, verb) => verb == "engine-changed" + ); + + await extension.upgrade(extInfo("example", "Example", "2.0", "bar")); + await AddonTestUtils.waitForSearchProviderStartup(extension); + await promiseChanged; + + engine = await Services.search.getEngineByAlias("testing"); + Assert.ok(engine, "Engine still has alias set"); + + params = engine.getSubmission("test").uri.query.split("&"); + Assert.ok(params.includes("version=2.0"), "Correct version installed"); + + await extension.unload(); + await promiseAfterSettings(); +}); diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_valid.js b/toolkit/components/search/tests/xpcshell/test_webextensions_valid.js new file mode 100644 index 0000000000..a3bd56754b --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/test_webextensions_valid.js @@ -0,0 +1,207 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +SearchTestUtils.initXPCShellAddonManager(this); + +let extension; +let oldRemoveEngineFunc; + +async function getEngineNames() { + let engines = await Services.search.getEngines(); + return engines.map(engine => engine._name); +} + +add_task(async function setup() { + await SearchTestUtils.useTestEngines("simple-engines"); + await promiseStartupManager(); + + Services.telemetry.canRecordExtended = true; + + await Services.search.init(); + await promiseAfterSettings(); + + extension = await SearchTestUtils.installSearchExtension(); + await extension.awaitStartup(); + + // For these tests, stub-out the removeEngine function, so that when we + // remove it from the add-on manager, the engine is left in the search + // settings. + oldRemoveEngineFunc = Services.search.wrappedJSObject.removeEngine.bind( + Services.search.wrappedJSObject + ); + Services.search.wrappedJSObject.removeEngine = () => {}; + + registerCleanupFunction(async () => { + await promiseShutdownManager(); + }); +}); + +add_task(async function test_valid_extensions_do_nothing() { + Services.telemetry.clearScalars(); + + Assert.ok( + Services.search.getEngineByName("Example"), + "Should have installed the engine" + ); + + await Services.search.checkWebExtensionEngines(); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + + Assert.deepEqual(scalars, {}, "Should not have recorded any issues"); +}); + +add_task(async function test_different_name() { + Services.telemetry.clearScalars(); + + let engine = Services.search.getEngineByName("Example"); + + engine.wrappedJSObject._name = "Example Test"; + + await Services.search.checkWebExtensionEngines(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.searchinit.engine_invalid_webextension", + extension.id, + 5 + ); + + engine.wrappedJSObject._name = "Example"; +}); + +add_task(async function test_different_url() { + Services.telemetry.clearScalars(); + + let engine = Services.search.getEngineByName("Example"); + + engine.wrappedJSObject._urls = []; + engine.wrappedJSObject._setUrls({ + search_url: "https://example.com/123", + search_url_get_params: "?q={searchTerms}", + }); + + await Services.search.checkWebExtensionEngines(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.searchinit.engine_invalid_webextension", + extension.id, + 6 + ); +}); + +add_task(async function test_extension_no_longer_specifies_engine() { + Services.telemetry.clearScalars(); + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + applications: { + gecko: { + id: "example@tests.mozilla.org", + }, + }, + }, + }; + + await extension.upgrade(extensionInfo); + + await Services.search.checkWebExtensionEngines(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.searchinit.engine_invalid_webextension", + extension.id, + 4 + ); +}); + +add_task(async function test_disabled_extension() { + // We don't clear scalars between tests to ensure the scalar gets set + // to the new value, rather than added. + + // Disable the extension, this won't remove the search engine because we've + // stubbed removeEngine. + await extension.addon.disable(); + + await Services.search.checkWebExtensionEngines(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.searchinit.engine_invalid_webextension", + extension.id, + 2 + ); + + extension.addon.enable(); + await extension.awaitStartup(); +}); + +add_task(async function test_missing_extension() { + // We don't clear scalars between tests to ensure the scalar gets set + // to the new value, rather than added. + + let extensionId = extension.id; + // Remove the extension, this won't remove the search engine because we've + // stubbed removeEngine. + await extension.unload(); + + await Services.search.checkWebExtensionEngines(); + + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "browser.searchinit.engine_invalid_webextension", + extensionId, + 1 + ); + + await oldRemoveEngineFunc(Services.search.getEngineByName("Example")); +}); + +add_task(async function test_user_engine() { + Services.telemetry.clearScalars(); + + await Services.search.addUserEngine("test", "https://example.com/", "fake"); + + await Services.search.checkWebExtensionEngines(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + + Assert.deepEqual( + scalars, + {}, + "Should not have recorded any issues for a user-defined engine" + ); +}); + +add_task(async function test_policy_engine() { + Services.telemetry.clearScalars(); + + await Services.search.addPolicyEngine({ + description: "test policy engine", + chrome_settings_overrides: { + search_provider: { + name: "test_policy_engine", + search_url: "https://www.example.org/?search={searchTerms}", + }, + }, + }); + + await Services.search.checkWebExtensionEngines(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + + Assert.deepEqual( + scalars, + {}, + "Should not have recorded any issues for a policy defined engine" + ); +}); diff --git a/toolkit/components/search/tests/xpcshell/xpcshell.ini b/toolkit/components/search/tests/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..ddc31fdc02 --- /dev/null +++ b/toolkit/components/search/tests/xpcshell/xpcshell.ini @@ -0,0 +1,155 @@ +[DEFAULT] +firefox-appdir = browser +head = head_search.js +dupe-manifest = +tags=searchmain +skip-if = toolkit == 'android' + +support-files = + data/engine.xml + data/engine/manifest.json + data/engine2.xml + data/engine2/manifest.json + data/engine-app/manifest.json + data/engine-fr.xml + data/engine-fr/manifest.json + data/engine-reordered/manifest.json + data/engineMaker.sjs + data/engine-pref/manifest.json + data/engine-rel-searchform-purpose/manifest.json + data/engine-system-purpose/manifest.json + data/engineImages.xml + data/engine-chromeicon/manifest.json + data/engine-chromeicon.xml + data/engine-resourceicon/manifest.json + data/engine-resourceicon/_locales/en/messages.json + data/engine-resourceicon/_locales/gd/messages.json + data/engine-resourceicon.xml + data/engine-same-name/manifest.json + data/engine-same-name/_locales/en/messages.json + data/engine-same-name/_locales/gd/messages.json + data/engines-no-order-hint.json + data/engines.json + data/search.json + data/search-legacy.json + data/search-obsolete-distribution.json + data/search-obsolete-langpack.json + data/searchSuggestions.sjs + data/geolookup-extensions/multilocale/favicon.ico + data/geolookup-extensions/multilocale/manifest.json + data/geolookup-extensions/multilocale/_locales/af/messages.json + data/geolookup-extensions/multilocale/_locales/an/messages.json + data1/engine1/manifest.json + data1/engine2/manifest.json + data1/engines.json + simple-engines/engines.json + simple-engines/basic/manifest.json + simple-engines/hidden/manifest.json + simple-engines/simple/manifest.json + test-extensions/engines.json + test-extensions/plainengine/favicon.ico + test-extensions/plainengine/manifest.json + test-extensions/special-engine/favicon.ico + test-extensions/special-engine/manifest.json + test-extensions/multilocale/favicon-af.ico + test-extensions/multilocale/favicon-an.ico + test-extensions/multilocale/manifest.json + test-extensions/multilocale/_locales/af/messages.json + test-extensions/multilocale/_locales/an/messages.json + +[test_addEngineWithDetails.js] +[test_addEngineWithDetailsObject.js] +[test_addEngineWithExtensionID.js] +[test_async.js] +[test_bug930456_child.js] +skip-if = true # Is confusing +[test_bug930456.js] +[test_config_attribution.js] +[test_config_engine_params.js] +support-files = + method-extensions/get/manifest.json + method-extensions/post/manifest.json + method-extensions/engines.json +[test_defaultEngine_fallback.js] +[test_defaultEngine.js] +[test_defaultPrivateEngine.js] +[test_engine_alias.js] +[test_engine_multiple_alias.js] +[test_engine_selector_application_distribution.js] +[test_engine_selector_application_name.js] +[test_engine_selector_application.js] +[test_engine_selector_order.js] +[test_engine_selector_override.js] +[test_engine_selector_remote_settings.js] +tag = remotesettings searchmain +[test_engine_selector.js] +[test_engine_set_alias.js] +[test_getSubmission_encoding.js] +[test_getSubmission_params.js] +[test_identifiers.js] +[test_ignorelist_update.js] +[test_ignorelist.js] +[test_initialization_with_region.js] +[test_initialization.js] +[test_list_json_locale.js] +[test_list_json_no_private_default.js] +[test_list_json_searchdefault.js] +[test_list_json_searchorder.js] +[test_maybereloadengine_order.js] +[test_migrateWebExtensionEngine.js] +[test_missing_engine.js] +[test_multipleIcons.js] +[test_nodb_pluschanges.js] +[test_notifications.js] +[test_opensearch_icon.js] +support-files = + data/bigIcon.ico + data/remoteIcon.ico + data/svgIcon.svg +[test_opensearch_icons_invalid.js] +[test_opensearch_install_errors.js] +support-files = opensearch/invalid.xml +[test_opensearch_update.js] +[test_opensearch.js] +support-files = + opensearch/mozilla-ns.xml + opensearch/post.xml + opensearch/simple.xml + opensearch/suggestion.xml + opensearch/suggestion-alternate.xml +[test_originalDefaultEngine.js] +[test_override_allowlist.js] +[test_parseSubmissionURL.js] +[test_pref.js] +[test_purpose.js] +[test_region_params.js] +[test_reload_engines_experiment.js] +[test_reload_engines.js] +[test_remove_profile_engine.js] +[test_resultDomain.js] +[test_save_sorted_engines.js] +[test_SearchStaticData.js] +[test_searchSuggest_cookies.js] +[test_searchSuggest_private.js] +[test_searchSuggest.js] +[test_selectedEngine.js] +[test_sendSubmissionURL.js] +[test_settings_broken.js] +[test_settings_duplicate.js] +[test_settings_good.js] +[test_settings_ignorelist.js] +support-files = data/search_ignorelist.json +[test_settings_none.js] +[test_settings_obsolete.js] +[test_settings_persist.js] +[test_settings.js] +[test_sort_orders-no-hints.js] +[test_sort_orders.js] +[test_validate_engines.js] +[test_validate_manifests.js] +[test_webextensions_builtin_upgrade.js] +[test_webextensions_install.js] +[test_webextensions_normandy_upgrade.js] +[test_webextensions_startup_remove.js] +[test_webextensions_upgrade.js] +[test_webextensions_valid.js] diff --git a/toolkit/components/search/toolkitsearch.manifest b/toolkit/components/search/toolkitsearch.manifest new file mode 100644 index 0000000000..c69934e2d3 --- /dev/null +++ b/toolkit/components/search/toolkitsearch.manifest @@ -0,0 +1,2 @@ +# 21600 == 6 hours +category update-timer nsSearchService @mozilla.org/browser/search-service;1,getService,search-engine-update-timer,browser.search.update.interval,21600 |