summaryrefslogtreecommitdiffstats
path: root/toolkit/components/search
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/search')
-rw-r--r--toolkit/components/search/.eslintrc.js9
-rw-r--r--toolkit/components/search/AddonSearchEngine.sys.mjs454
-rw-r--r--toolkit/components/search/OpenSearchEngine.sys.mjs464
-rw-r--r--toolkit/components/search/PolicySearchEngine.sys.mjs108
-rw-r--r--toolkit/components/search/SearchEngine.sys.mjs1783
-rw-r--r--toolkit/components/search/SearchEngineSelector.sys.mjs447
-rw-r--r--toolkit/components/search/SearchService.sys.mjs3699
-rw-r--r--toolkit/components/search/SearchSettings.sys.mjs603
-rw-r--r--toolkit/components/search/SearchStaticData.sys.mjs37
-rw-r--r--toolkit/components/search/SearchSuggestionController.sys.mjs806
-rw-r--r--toolkit/components/search/SearchSuggestions.sys.mjs221
-rw-r--r--toolkit/components/search/SearchUtils.sys.mjs388
-rw-r--r--toolkit/components/search/UserSearchEngine.sys.mjs53
-rw-r--r--toolkit/components/search/components.conf23
-rw-r--r--toolkit/components/search/docs/DefaultSearchEngines.rst102
-rw-r--r--toolkit/components/search/docs/Preferences.rst46
-rw-r--r--toolkit/components/search/docs/SearchConfigurationSchema.rst586
-rw-r--r--toolkit/components/search/docs/SearchEngineConfiguration.rst72
-rw-r--r--toolkit/components/search/docs/SearchEngines.rst197
-rw-r--r--toolkit/components/search/docs/SearchService.rst6
-rw-r--r--toolkit/components/search/docs/SearchServiceHighlevelOverview.rst85
-rw-r--r--toolkit/components/search/docs/Telemetry.rst81
-rw-r--r--toolkit/components/search/docs/assets/bugzilla-open-search1.pngbin0 -> 179185 bytes
-rw-r--r--toolkit/components/search/docs/assets/bugzilla-open-search2.pngbin0 -> 211617 bytes
-rw-r--r--toolkit/components/search/docs/assets/search-service-diagram.pngbin0 -> 134442 bytes
-rw-r--r--toolkit/components/search/docs/index.rst40
-rw-r--r--toolkit/components/search/docs/search-add-on-prompts-flow.pngbin0 -> 79061 bytes
-rw-r--r--toolkit/components/search/metrics.yaml367
-rw-r--r--toolkit/components/search/moz.build44
-rw-r--r--toolkit/components/search/nsISearchService.idl549
-rw-r--r--toolkit/components/search/schema/Readme.txt7
-rw-r--r--toolkit/components/search/schema/search-default-override-allowlist-schema.json49
-rw-r--r--toolkit/components/search/schema/search-default-override-allowlist-ui-schema.json3
-rw-r--r--toolkit/components/search/schema/search-engine-config-schema.json400
-rw-r--r--toolkit/components/search/schema/search-engine-config-ui-schema.json14
-rw-r--r--toolkit/components/search/tests/SearchTestUtils.sys.mjs505
-rw-r--r--toolkit/components/search/tests/xpcshell/data/bigIcon.icobin0 -> 56646 bytes
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-app/manifest.json24
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-chromeicon/manifest.json23
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-diff-name/_locales/en/messages.json8
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-diff-name/_locales/gd/messages.json8
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-diff-name/manifest.json21
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-fr.xml12
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-fr/manifest.json32
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-override/manifest.json24
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-pref/manifest.json28
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-purposes/manifest.json62
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-rel-searchform-purpose/manifest.json41
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-reordered/manifest.json40
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/en/messages.json8
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-resourceicon/_locales/gd/messages.json8
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-resourceicon/manifest.json21
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/en/messages.json8
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-same-name/_locales/gd/messages.json8
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-same-name/manifest.json21
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine-system-purpose/manifest.json35
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine.xml15
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine/manifest.json40
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine2.xml9
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engine2/manifest.json18
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engineImages.xml22
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engineImages/manifest.json37
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engineMaker.sjs77
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engines-no-order-hint.json83
-rw-r--r--toolkit/components/search/tests/xpcshell/data/engines.json98
-rw-r--r--toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/af/messages.json17
-rw-r--r--toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/_locales/an/messages.json17
-rw-r--r--toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/favicon.icobin0 -> 884 bytes
-rw-r--r--toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/manifest.json23
-rw-r--r--toolkit/components/search/tests/xpcshell/data/iconsRedirect.sjs18
-rw-r--r--toolkit/components/search/tests/xpcshell/data/remoteIcon.icobin0 -> 901 bytes
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-legacy-correct-default-engine-hashes.json112
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-legacy-no-ids.json87
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-legacy-old-loadPaths.json135
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-legacy-wrong-default-engine-hashes.json114
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-legacy-wrong-third-party-engine-hashes.json114
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-legacy.json109
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-migration.json68
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-obsolete-app.json41
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-obsolete-distribution.json41
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search-obsolete-langpack.json91
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search.json72
-rw-r--r--toolkit/components/search/tests/xpcshell/data/searchSuggestions.sjs181
-rw-r--r--toolkit/components/search/tests/xpcshell/data/search_ignorelist.json51
-rw-r--r--toolkit/components/search/tests/xpcshell/data/svgIcon.svg4
-rw-r--r--toolkit/components/search/tests/xpcshell/data1/engine1/manifest.json27
-rw-r--r--toolkit/components/search/tests/xpcshell/data1/engine2/manifest.json27
-rw-r--r--toolkit/components/search/tests/xpcshell/data1/engines.json60
-rw-r--r--toolkit/components/search/tests/xpcshell/data1/exp2/manifest.json27
-rw-r--r--toolkit/components/search/tests/xpcshell/data1/exp3/manifest.json27
-rw-r--r--toolkit/components/search/tests/xpcshell/head_search.js512
-rw-r--r--toolkit/components/search/tests/xpcshell/method-extensions/engines.json45
-rw-r--r--toolkit/components/search/tests/xpcshell/method-extensions/get/manifest.json21
-rw-r--r--toolkit/components/search/tests/xpcshell/method-extensions/post/manifest.json21
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/chromeicon.xml9
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/insecure-and-insecurely-updated1.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/insecure-and-insecurely-updated2.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/insecure-and-no-update-url1.xml11
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/insecure-and-securely-updated1.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/invalid.xml1
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/mozilla-ns.xml12
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/post.xml8
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/resourceicon.xml9
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-and-insecurely-updated1.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-and-insecurely-updated2.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-and-no-update-url1.xml11
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated-insecure-form.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated1.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated2.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated3.xml14
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-localhost.xml11
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-onionv2.xml11
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/secure-onionv3.xml11
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/simple.xml11
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/suggestion-alternate.xml16
-rw-r--r--toolkit/components/search/tests/xpcshell/opensearch/suggestion.xml15
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/head_searchconfig.js614
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_amazon.js361
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_baidu.js43
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_bing.js133
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_distributions.js346
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_duckduckgo.js35
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_ebay.js290
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_ecosia.js37
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_google.js173
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_mailru.js38
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_qwant.js38
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_rakuten.js37
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_selector_db_out_of_date.js58
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_yahoojp.js38
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/test_yandex.js117
-rw-r--r--toolkit/components/search/tests/xpcshell/searchconfigs/xpcshell.ini34
-rw-r--r--toolkit/components/search/tests/xpcshell/simple-engines/basic/manifest.json29
-rw-r--r--toolkit/components/search/tests/xpcshell/simple-engines/engines.json27
-rw-r--r--toolkit/components/search/tests/xpcshell/simple-engines/hidden/manifest.json29
-rw-r--r--toolkit/components/search/tests/xpcshell/simple-engines/simple/manifest.json29
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/engines.json58
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/af/messages.json20
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/multilocale/_locales/an/messages.json20
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-af.icobin0 -> 884 bytes
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-an.icobin0 -> 5430 bytes
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/multilocale/manifest.json23
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/plainengine/favicon.icobin0 -> 5430 bytes
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/plainengine/manifest.json58
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/special-engine/favicon.icobin0 -> 5430 bytes
-rw-r--r--toolkit/components/search/tests/xpcshell/test-extensions/special-engine/manifest.json40
-rw-r--r--toolkit/components/search/tests/xpcshell/test_SearchStaticData.js40
-rw-r--r--toolkit/components/search/tests/xpcshell/test_appDefaultEngine.js41
-rw-r--r--toolkit/components/search/tests/xpcshell/test_async.js40
-rw-r--r--toolkit/components/search/tests/xpcshell/test_config_attribution.js29
-rw-r--r--toolkit/components/search/tests/xpcshell/test_config_engine_params.js68
-rw-r--r--toolkit/components/search/tests/xpcshell/test_defaultEngine.js121
-rw-r--r--toolkit/components/search/tests/xpcshell/test_defaultEngine_experiments.js422
-rw-r--r--toolkit/components/search/tests/xpcshell/test_defaultEngine_fallback.js406
-rw-r--r--toolkit/components/search/tests/xpcshell/test_defaultPrivateEngine.js582
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_alias.js31
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_ids.js144
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_multiple_alias.js23
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_selector.js241
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_selector_application.js115
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_selector_application_distribution.js120
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_selector_application_name.js126
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_selector_order.js136
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_selector_override.js181
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_selector_remote_settings.js343
-rw-r--r--toolkit/components/search/tests/xpcshell/test_engine_set_alias.js132
-rw-r--r--toolkit/components/search/tests/xpcshell/test_getSubmission_encoding.js29
-rw-r--r--toolkit/components/search/tests/xpcshell/test_getSubmission_params.js94
-rw-r--r--toolkit/components/search/tests/xpcshell/test_identifiers.js60
-rw-r--r--toolkit/components/search/tests/xpcshell/test_ignorelist.js72
-rw-r--r--toolkit/components/search/tests/xpcshell/test_ignorelist_update.js89
-rw-r--r--toolkit/components/search/tests/xpcshell/test_initialization.js59
-rw-r--r--toolkit/components/search/tests/xpcshell/test_initialization_with_region.js130
-rw-r--r--toolkit/components/search/tests/xpcshell/test_list_json_locale.js106
-rw-r--r--toolkit/components/search/tests/xpcshell/test_list_json_no_private_default.js49
-rw-r--r--toolkit/components/search/tests/xpcshell/test_list_json_searchdefault.js70
-rw-r--r--toolkit/components/search/tests/xpcshell/test_list_json_searchorder.js66
-rw-r--r--toolkit/components/search/tests/xpcshell/test_location_timeout_xhr.js90
-rw-r--r--toolkit/components/search/tests/xpcshell/test_maybereloadengine_order.js53
-rw-r--r--toolkit/components/search/tests/xpcshell/test_migrateWebExtensionEngine.js89
-rw-r--r--toolkit/components/search/tests/xpcshell/test_missing_engine.js104
-rw-r--r--toolkit/components/search/tests/xpcshell/test_multipleIcons.js60
-rw-r--r--toolkit/components/search/tests/xpcshell/test_nodb_pluschanges.js52
-rw-r--r--toolkit/components/search/tests/xpcshell/test_notifications.js125
-rw-r--r--toolkit/components/search/tests/xpcshell/test_opensearch.js160
-rw-r--r--toolkit/components/search/tests/xpcshell/test_opensearch_icon.js63
-rw-r--r--toolkit/components/search/tests/xpcshell/test_opensearch_icons_invalid.js49
-rw-r--r--toolkit/components/search/tests/xpcshell/test_opensearch_install_errors.js67
-rw-r--r--toolkit/components/search/tests/xpcshell/test_opensearch_telemetry.js62
-rw-r--r--toolkit/components/search/tests/xpcshell/test_opensearch_update.js120
-rw-r--r--toolkit/components/search/tests/xpcshell/test_override_allowlist.js391
-rw-r--r--toolkit/components/search/tests/xpcshell/test_parseSubmissionURL.js182
-rw-r--r--toolkit/components/search/tests/xpcshell/test_policyEngine.js185
-rw-r--r--toolkit/components/search/tests/xpcshell/test_pref.js70
-rw-r--r--toolkit/components/search/tests/xpcshell/test_purpose.js83
-rw-r--r--toolkit/components/search/tests/xpcshell/test_reload_engines.js316
-rw-r--r--toolkit/components/search/tests/xpcshell/test_reload_engines_experiment.js133
-rw-r--r--toolkit/components/search/tests/xpcshell/test_reload_engines_locales.js95
-rw-r--r--toolkit/components/search/tests/xpcshell/test_remove_engine_notification_box.js392
-rw-r--r--toolkit/components/search/tests/xpcshell/test_remove_profile_engine.js46
-rw-r--r--toolkit/components/search/tests/xpcshell/test_save_sorted_engines.js67
-rw-r--r--toolkit/components/search/tests/xpcshell/test_searchSuggest.js891
-rw-r--r--toolkit/components/search/tests/xpcshell/test_searchSuggest_cookies.js143
-rw-r--r--toolkit/components/search/tests/xpcshell/test_searchSuggest_extraParams.js50
-rw-r--r--toolkit/components/search/tests/xpcshell/test_searchSuggest_private.js54
-rw-r--r--toolkit/components/search/tests/xpcshell/test_searchTermFromResult.js303
-rw-r--r--toolkit/components/search/tests/xpcshell/test_searchUrlDomain.js21
-rw-r--r--toolkit/components/search/tests/xpcshell/test_selectedEngine.js155
-rw-r--r--toolkit/components/search/tests/xpcshell/test_sendSubmissionURL.js90
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings.js616
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_broken.js131
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_duplicate.js134
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_good.js103
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_ignorelist.js62
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_migration_ids.js122
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_migration_loadPath.js130
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_none.js50
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_obsolete.js83
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_persist.js89
-rw-r--r--toolkit/components/search/tests/xpcshell/test_settings_persist_diff_locale_same_name.js68
-rw-r--r--toolkit/components/search/tests/xpcshell/test_sort_orders-no-hints.js69
-rw-r--r--toolkit/components/search/tests/xpcshell/test_sort_orders.js91
-rw-r--r--toolkit/components/search/tests/xpcshell/test_telemetry_event_default.js293
-rw-r--r--toolkit/components/search/tests/xpcshell/test_userEngine.js56
-rw-r--r--toolkit/components/search/tests/xpcshell/test_validate_engines.js36
-rw-r--r--toolkit/components/search/tests/xpcshell/test_validate_manifests.js64
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_builtin_upgrade.js252
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_install.js205
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_language_switch.js110
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_migrate_to.js66
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_normandy_upgrade.js93
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_startup_remove.js83
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_upgrade.js188
-rw-r--r--toolkit/components/search/tests/xpcshell/test_webextensions_valid.js165
-rw-r--r--toolkit/components/search/tests/xpcshell/xpcshell.ini196
235 files changed, 30609 insertions, 0 deletions
diff --git a/toolkit/components/search/.eslintrc.js b/toolkit/components/search/.eslintrc.js
new file mode 100644
index 0000000000..9aafb4a214
--- /dev/null
+++ b/toolkit/components/search/.eslintrc.js
@@ -0,0 +1,9 @@
+/* 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 = {
+ extends: ["plugin:mozilla/require-jsdoc"],
+};
diff --git a/toolkit/components/search/AddonSearchEngine.sys.mjs b/toolkit/components/search/AddonSearchEngine.sys.mjs
new file mode 100644
index 0000000000..4a469eaa02
--- /dev/null
+++ b/toolkit/components/search/AddonSearchEngine.sys.mjs
@@ -0,0 +1,454 @@
+/* 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 */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { SearchEngine } from "resource://gre/modules/SearchEngine.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "AddonSearchEngine",
+ maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
+ });
+});
+
+/**
+ * AddonSearchEngine represents a search engine defined by an add-on.
+ */
+export class AddonSearchEngine extends SearchEngine {
+ // Whether the engine is provided by the application.
+ #isAppProvided = false;
+ // The extension ID if added by an extension.
+ _extensionID = null;
+ // The locale, or "DEFAULT", if required.
+ _locale = null;
+
+ /**
+ * Creates a AddonSearchEngine.
+ *
+ * @param {object} options
+ * The options object
+ * @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 {object} [options.details]
+ * An object that simulates the manifest object from a WebExtension.
+ * @param {object} [options.json]
+ * An object that represents the saved JSON settings for the engine.
+ */
+ constructor({ isAppProvided, details, json } = {}) {
+ let extensionId =
+ details?.extensionID ?? json.extensionID ?? json._extensionID;
+ let id = extensionId + (details?.locale ?? json._locale);
+
+ super({
+ loadPath: "[addon]" + extensionId,
+ id,
+ });
+
+ this._extensionID = extensionId;
+ this.#isAppProvided = isAppProvided;
+
+ if (json) {
+ this._initWithJSON(json);
+ }
+ }
+
+ _initWithJSON(json) {
+ super._initWithJSON(json);
+ this._extensionID = json.extensionID || json._extensionID || null;
+ this._locale = json.extensionLocale || json._locale || null;
+ }
+
+ /**
+ * Call to initalise an instance with extension details. Does not need to be
+ * called if json has been passed to the constructor.
+ *
+ * @param {object} options
+ * The options object.
+ * @param {Extension} options.extension
+ * The extension object representing the add-on.
+ * @param {object} options.locale
+ * The locale to use from the extension for getting details of the search
+ * engine.
+ * @param {object} [options.config]
+ * The search engine configuration for application provided engines, that
+ * may be overriding some of the WebExtension's settings.
+ */
+ async init({ extension, locale, config }) {
+ let { baseURI, manifest } = await this.#getExtensionDetailsForLocale(
+ extension,
+ locale
+ );
+
+ this.#initFromManifest(baseURI, manifest, locale, config);
+ }
+
+ /**
+ * Manages updates to this engine.
+ *
+ * @param {object} options
+ * The options object.
+ * @param {object} [options.configuration]
+ * The search engine configuration for application provided engines, that
+ * may be overriding some of the WebExtension's settings.
+ * @param {object} [options.extension]
+ * The extension associated with this search engine, if known.
+ * @param {object} [options.manifest]
+ * The extension's manifest associated with this search engine, if known.
+ * @param {string} [options.locale]
+ * The locale to use from the extension for getting details of the search
+ * engine.
+ */
+ async update({ configuration, extension, manifest, locale } = {}) {
+ let baseURI = extension?.baseURI;
+ if (!manifest) {
+ ({ baseURI, manifest } = await this.#getExtensionDetailsForLocale(
+ extension,
+ locale
+ ));
+ }
+ let originalName = this.name;
+ let name = manifest.chrome_settings_overrides.search_provider.name.trim();
+ if (originalName != name && Services.search.getEngineByName(name)) {
+ throw new Error("Can't upgrade to the same name as an existing engine");
+ }
+
+ this.#updateFromManifest(baseURI, manifest, locale, configuration);
+ }
+
+ /**
+ * This will update the add-on search engine if there is no name change.
+ *
+ * @param {object} options
+ * The options object.
+ * @param {object} [options.configuration]
+ * The search engine configuration for application provided engines, that
+ * may be overriding some of the WebExtension's settings.
+ * @param {string} [options.locale]
+ * The locale to use from the extension for getting details of the search
+ * engine.
+ * @returns {boolean}
+ * Returns true if the engine was updated, false otherwise.
+ */
+ async updateIfNoNameChange({ configuration, locale }) {
+ let { baseURI, manifest } = await this.#getExtensionDetailsForLocale(
+ null,
+ locale
+ );
+
+ if (
+ this.name !=
+ manifest.chrome_settings_overrides.search_provider.name.trim()
+ ) {
+ return false;
+ }
+
+ this.#updateFromManifest(baseURI, manifest, locale, configuration);
+ return true;
+ }
+
+ /**
+ * Whether or not this engine is provided by the application, e.g. it is
+ * in the list of configured search engines. Overrides the definition in
+ * `SearchEngine`.
+ *
+ * @returns {boolean}
+ */
+ get isAppProvided() {
+ return this.#isAppProvided;
+ }
+
+ /**
+ * Whether or not this engine is an in-memory only search engine.
+ * These engines are typically application provided or policy engines,
+ * where they are loaded every time on SearchService initialization
+ * using the policy JSON or the extension manifest. Minimal details of the
+ * in-memory engines are saved to disk, but they are never loaded
+ * from the user's saved settings file.
+ *
+ * @returns {boolean}
+ * Only returns true for application provided engines.
+ */
+ get inMemory() {
+ return this.#isAppProvided;
+ }
+
+ get isGeneralPurposeEngine() {
+ return !!(
+ this._extensionID &&
+ lazy.SearchUtils.GENERAL_SEARCH_ENGINE_IDS.has(this._extensionID)
+ );
+ }
+
+ /**
+ * 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 {
+ id: this.id,
+ _name: this.name,
+ _isAppProvided: true,
+ _metaData: this._metaData,
+ };
+ }
+ let json = super.toJSON();
+ json._extensionID = this._extensionID;
+ json._locale = this._locale;
+ return json;
+ }
+
+ /**
+ * Checks to see if this engine's settings are in sync with what the add-on
+ * manager has, and reports the results to telemetry.
+ */
+ async checkAndReportIfSettingsValid() {
+ let addon = await lazy.AddonManager.getAddonByID(this._extensionID);
+
+ if (!addon) {
+ lazy.logConsole.debug(
+ `Add-on ${this._extensionID} for search engine ${this.name} is not installed!`
+ );
+ Services.telemetry.keyedScalarSet(
+ "browser.searchinit.engine_invalid_webextension",
+ this._extensionID,
+ 1
+ );
+ } else if (!addon.isActive) {
+ lazy.logConsole.debug(
+ `Add-on ${this._extensionID} for search engine ${this.name} is not active!`
+ );
+ Services.telemetry.keyedScalarSet(
+ "browser.searchinit.engine_invalid_webextension",
+ this._extensionID,
+ 2
+ );
+ } else {
+ let policy = await AddonSearchEngine.getWebExtensionPolicy(
+ this._extensionID
+ );
+ let providerSettings =
+ policy.extension.manifest?.chrome_settings_overrides?.search_provider;
+
+ if (!providerSettings) {
+ lazy.logConsole.debug(
+ `Add-on ${this._extensionID} for search engine ${this.name} no longer has an engine defined`
+ );
+ Services.telemetry.keyedScalarSet(
+ "browser.searchinit.engine_invalid_webextension",
+ this._extensionID,
+ 4
+ );
+ } else if (this.name != providerSettings.name) {
+ lazy.logConsole.debug(
+ `Add-on ${this._extensionID} for search engine ${this.name} has a different name!`
+ );
+ Services.telemetry.keyedScalarSet(
+ "browser.searchinit.engine_invalid_webextension",
+ this._extensionID,
+ 5
+ );
+ } else if (!this.checkSearchUrlMatchesManifest(providerSettings)) {
+ lazy.logConsole.debug(
+ `Add-on ${this._extensionID} for search engine ${this.name} has out-of-date manifest!`
+ );
+ Services.telemetry.keyedScalarSet(
+ "browser.searchinit.engine_invalid_webextension",
+ this._extensionID,
+ 6
+ );
+ }
+ }
+ }
+
+ /**
+ * Initializes the engine based on the manifest and other values.
+ *
+ * @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(extensionBaseURI, manifest, locale, configuration = {}) {
+ let searchProvider = manifest.chrome_settings_overrides.search_provider;
+
+ this._locale = locale;
+
+ // 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 = this._extensionID.split("@")[0];
+ if (locale != lazy.SearchUtils.DEFAULT_TAG) {
+ telemetryId += "-" + locale;
+ }
+ this._telemetryId = telemetryId;
+ }
+ }
+
+ // Set the main icon URL for the engine.
+ let iconURL = searchProvider.favicon_url;
+
+ if (!iconURL) {
+ iconURL =
+ manifest.icons &&
+ extensionBaseURI.resolve(
+ lazy.ExtensionParent.IconDetails.getPreferredIcon(manifest.icons).icon
+ );
+ }
+
+ // Record other icons that the WebExtension has.
+ if (manifest.icons) {
+ let iconList = Object.entries(manifest.icons).map(icon => {
+ return {
+ width: icon[0],
+ height: icon[0],
+ url: extensionBaseURI.resolve(icon[1]),
+ };
+ });
+ for (let icon of iconList) {
+ this._addIconToMap(icon.size, icon.size, icon.url);
+ }
+ }
+
+ // 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_"));
+ });
+ }
+
+ this._initWithDetails(
+ { ...searchProvider, iconURL, description: manifest.description },
+ configuration
+ );
+ }
+
+ /**
+ * Update this engine based on new manifest, used during
+ * webextension upgrades.
+ *
+ * @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(extensionBaseURI, manifest, locale, configuration = {}) {
+ this._urls = [];
+ this._iconMapObj = null;
+ this.#initFromManifest(extensionBaseURI, manifest, locale, configuration);
+ lazy.SearchUtils.notifyAction(this, lazy.SearchUtils.MODIFIED_TYPE.CHANGED);
+ }
+
+ /**
+ * Get the localized manifest from the WebExtension for the given locale or
+ * manifest default locale.
+ *
+ * The search service configuration overloads the add-on manager concepts of
+ * locales, and forces particular locales within the WebExtension to be used,
+ * ignoring the user's current locale. The user's current locale is taken into
+ * account within the configuration, just not in the WebExtension.
+ *
+ * @param {object} [extension]
+ * The extension to get the manifest from.
+ * @param {string} locale
+ * The locale to load from the WebExtension. If this is `DEFAULT_TAG`, then
+ * the default locale is loaded.
+ * @returns {object}
+ * The loaded manifest.
+ */
+ async #getExtensionDetailsForLocale(extension, locale) {
+ // If we haven't been passed an extension object, then go and find it.
+ if (!extension) {
+ extension = (
+ await AddonSearchEngine.getWebExtensionPolicy(this._extensionID)
+ ).extension;
+ }
+
+ let manifest = extension.manifest;
+
+ let localeToLoad;
+ if (this.#isAppProvided) {
+ // If the locale we want from the WebExtension is the extension's default
+ // then we get that from the manifest here. We do this because if we
+ // are reloading due to the locale change, the add-on manager might not
+ // have updated the WebExtension's manifest to the new version by the
+ // time we hit this code.
+ localeToLoad =
+ locale == lazy.SearchUtils.DEFAULT_TAG
+ ? manifest.default_locale
+ : locale;
+ } else {
+ // For user installed add-ons, we have to simulate the add-on manager
+ // code for loading the correct locale.
+ // We do this, as in the case of a live language switch, the add-on manager
+ // may not have yet reloaded the extension, and there's no way for us to
+ // listen for that reload to complete.
+ // See also https://bugzilla.mozilla.org/show_bug.cgi?id=1781768#c3 for
+ // more background.
+ localeToLoad = Services.locale.negotiateLanguages(
+ Services.locale.appLocalesAsBCP47,
+ [...extension.localeData.locales.keys()],
+ manifest.default_locale
+ )[0];
+ }
+
+ if (localeToLoad) {
+ manifest = await extension.getLocalizedManifest(localeToLoad);
+ }
+ return { baseURI: extension.baseURI, manifest };
+ }
+
+ /**
+ * Gets the WebExtensionPolicy for an add-on.
+ *
+ * @param {string} id
+ * The WebExtension id.
+ * @returns {WebExtensionPolicy}
+ */
+ static async getWebExtensionPolicy(id) {
+ let policy = WebExtensionPolicy.getByID(id);
+ if (!policy) {
+ let idPrefix = id.split("@")[0];
+ let path = `resource://search-extensions/${idPrefix}/`;
+ await lazy.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;
+ }
+}
diff --git a/toolkit/components/search/OpenSearchEngine.sys.mjs b/toolkit/components/search/OpenSearchEngine.sys.mjs
new file mode 100644
index 0000000000..d3d6da576a
--- /dev/null
+++ b/toolkit/components/search/OpenSearchEngine.sys.mjs
@@ -0,0 +1,464 @@
+/* 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 */
+
+import {
+ EngineURL,
+ SearchEngine,
+} from "resource://gre/modules/SearchEngine.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "OpenSearchEngine",
+ maxLogLevel: lazy.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.
+ */
+export class OpenSearchEngine extends SearchEngine {
+ // The data describing the engine, in the form of an XML document element.
+ _data = null;
+ // 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;
+
+ /**
+ * Creates a OpenSearchEngine.
+ *
+ * @param {object} [options]
+ * The options object
+ * @param {object} [options.json]
+ * An object that represents the saved JSON settings for the engine.
+ * @param {boolean} [options.shouldPersist]
+ * A flag indicating whether the engine should be persisted to disk and made
+ * available wherever engines are used (e.g. it can be set as the default
+ * search engine, used for search shortcuts, etc.). Non-persisted engines
+ * are intended for more limited or temporary use. Defaults to true.
+ */
+ constructor(options = {}) {
+ super({
+ // We don't know what this is until after it has loaded, so add a placeholder.
+ loadPath: options.json?._loadPath ?? "[opensearch]loading",
+ });
+
+ if (options.json) {
+ this._initWithJSON(options.json);
+ this._updateInterval = options.json._updateInterval ?? null;
+ this._updateURL = options.json._updateURL ?? null;
+ this._iconUpdateURL = options.json._iconUpdateURL ?? null;
+ }
+
+ this._shouldPersist = options.shouldPersist ?? true;
+ }
+ /**
+ * Creates a JavaScript object that represents this engine.
+ *
+ * @returns {object}
+ * An object suitable for serialization as JSON.
+ */
+ toJSON() {
+ let json = super.toJSON();
+ json._updateInterval = this._updateInterval;
+ json._updateURL = this._updateURL;
+ json._iconUpdateURL = this._iconUpdateURL;
+ return json;
+ }
+
+ /**
+ * 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.
+ */
+ install(uri, callback) {
+ let loadURI =
+ uri instanceof Ci.nsIURI ? uri : lazy.SearchUtils.makeURI(uri);
+ if (!loadURI) {
+ throw Components.Exception(
+ loadURI,
+ "Must have URI when calling _install!",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ if (!/^https?$/i.test(loadURI.scheme)) {
+ throw Components.Exception(
+ "Invalid URI passed to SearchEngine constructor",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ lazy.logConsole.debug("_install: Downloading engine from:", loadURI.spec);
+
+ var chan = lazy.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 lazy.SearchUtils.LoadListener(
+ chan,
+ /(^text\/|xml$)/,
+ this._onLoad.bind(this, callback)
+ );
+ chan.notificationCallbacks = listener;
+ chan.asyncOpen(listener);
+ }
+
+ /**
+ * 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) {
+ lazy.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) {
+ lazy.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);
+ lazy.logConsole.debug("_onLoad: duplicate engine found, bailing");
+ return;
+ }
+
+ this._loadPath = OpenSearchEngine.getAnonymizedLoadPath(
+ lazy.SearchUtils.sanitizeName(this.name),
+ this._uri
+ );
+ if (this._extensionID) {
+ this._loadPath += ":" + this._extensionID;
+ }
+ this.setAttr(
+ "loadPathHash",
+ lazy.SearchUtils.getVerificationHash(this._loadPath)
+ );
+ }
+
+ if (this._shouldPersist) {
+ // Notify the search service of the successful load. It will deal with
+ // updates by checking this._engineToUpdate.
+ lazy.SearchUtils.notifyAction(
+ this,
+ lazy.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))
+ ) {
+ lazy.logConsole.debug("Initing search plugin from", this._location);
+
+ this._parse();
+ } else {
+ console.error("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 = lazy.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
+ lazy.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) {
+ lazy.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.
+ lazy.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(lazy.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(
+ lazy.SearchUtils.URL_TYPE.OPENSEARCH,
+ "self"
+ );
+ return !!(this._updateURL || this._iconUpdateURL || selfURL);
+ }
+
+ /**
+ * Returns the engine's updateURI if it exists and returns null otherwise
+ *
+ * @returns {string?}
+ */
+ get _updateURI() {
+ let updateURL = this._getURLOfType(lazy.SearchUtils.URL_TYPE.OPENSEARCH);
+ let updateURI =
+ updateURL && updateURL._hasRelation("self")
+ ? updateURL.getSubmission("", this).uri
+ : lazy.SearchUtils.makeURI(this._updateURL);
+ return updateURI;
+ }
+
+ // 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, uri) {
+ return `[${uri.scheme}]${uri.host}/${shortName}.xml`;
+ }
+}
diff --git a/toolkit/components/search/PolicySearchEngine.sys.mjs b/toolkit/components/search/PolicySearchEngine.sys.mjs
new file mode 100644
index 0000000000..398e7ccafc
--- /dev/null
+++ b/toolkit/components/search/PolicySearchEngine.sys.mjs
@@ -0,0 +1,108 @@
+/* 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 */
+
+import { SearchEngine } from "resource://gre/modules/SearchEngine.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+/**
+ * PolicySearchEngine represents a search engine defined by an enterprise
+ * policy.
+ */
+export class PolicySearchEngine extends SearchEngine {
+ /**
+ * Creates a PolicySearchEngine.
+ *
+ * @param {object} options
+ * The options for this search engine.
+ * @param {object} [options.details]
+ * An object with the details for this search engine. See
+ * nsISearchService.addPolicyEngine for more details.
+ * @param {object} [options.json]
+ * An object that represents the saved JSON settings for the engine.
+ */
+ constructor(options = {}) {
+ let id = "policy-" + (options.details?.name ?? options.json._name);
+
+ super({
+ loadPath: "[policy]",
+ id,
+ });
+
+ if (options.details) {
+ this._initWithDetails(options.details);
+ } else {
+ this._initWithJSON(options.json);
+ }
+ }
+
+ /**
+ * Whether or not this engine is an in-memory only search engine.
+ * These engines are typically application provided or policy engines,
+ * where they are loaded every time on SearchService initialization
+ * using the policy JSON or the extension manifest. Minimal details of the
+ * in-memory engines are saved to disk, but they are never loaded
+ * from the user's saved settings file.
+ *
+ * @returns {boolean}
+ * All policy engines are in-memory, so this always returns true.
+ */
+ get inMemory() {
+ return true;
+ }
+
+ /**
+ * Returns the appropriate identifier to use for telemetry.
+ *
+ * @returns {string}
+ */
+ get telemetryId() {
+ return `other-${this.name}`;
+ }
+
+ /**
+ * Updates a search engine that is specified from enterprise policies.
+ *
+ * @param {object} details
+ * An object that simulates the manifest object from a WebExtension. See
+ * nsISearchService.updatePolicyEngine for more details.
+ */
+ update(details) {
+ this._urls = [];
+ this._iconMapObj = null;
+
+ this._initWithDetails(details);
+
+ lazy.SearchUtils.notifyAction(this, lazy.SearchUtils.MODIFIED_TYPE.CHANGED);
+ }
+
+ /**
+ * Creates a JavaScript object that represents this engine.
+ *
+ * @returns {object}
+ * An object suitable for serialization as JSON.
+ */
+ toJSON() {
+ // For policy engines, we load them at every startup and we don't want to
+ // store all their data in the settings file so just return the relevant
+ // metadata.
+ let json = super.toJSON();
+
+ // We only want to return a sub-set of fields, as the details for this engine
+ // are loaded on each startup from the enterprise policies.
+ return {
+ id: json.id,
+ _name: json._name,
+ // Load path is included so that we know this is an enterprise engine.
+ _loadPath: json._loadPath,
+ _metaData: json._metaData,
+ };
+ }
+}
diff --git a/toolkit/components/search/SearchEngine.sys.mjs b/toolkit/components/search/SearchEngine.sys.mjs
new file mode 100644
index 0000000000..bed5bdf9f8
--- /dev/null
+++ b/toolkit/components/search/SearchEngine.sys.mjs
@@ -0,0 +1,1783 @@
+/* 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 */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+const BinaryInputStream = Components.Constructor(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "SearchEngine",
+ maxLogLevel: lazy.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 > lazy.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() {
+ // Preference params are normally only on the default branch to avoid these being easily changed.
+ // We allow them on the normal branch in nightly builds to make testing easier.
+ let branchFetcher = AppConstants.NIGHTLY_BUILD
+ ? "getBranch"
+ : "getDefaultBranch";
+ this.branch = Services.prefs[branchFetcher](
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "param."
+ );
+ this.cache = new Map();
+ this.nimbusCache = new Map();
+ for (let prefName of this.branch.getChildList("")) {
+ this.cache.set(prefName, this.branch.getCharPref(prefName, null));
+ }
+ this.branch.addObserver("", this, true);
+
+ this.onNimbusUpdate = this.onNimbusUpdate.bind(this);
+ this.onNimbusUpdate();
+ lazy.NimbusFeatures.search.onUpdate(this.onNimbusUpdate);
+ lazy.NimbusFeatures.search.ready().then(this.onNimbusUpdate);
+ },
+
+ observe(subject, topic, data) {
+ this.cache.set(data, this.branch.getCharPref(data, null));
+ },
+
+ onNimbusUpdate() {
+ let extraParams =
+ lazy.NimbusFeatures.search.getVariable("extraParams") || [];
+ for (const { key, value } of extraParams) {
+ this.nimbusCache.set(key, value);
+ }
+ },
+
+ getPref(prefName) {
+ if (!this.cache) {
+ this.initCache();
+ }
+ return this.nimbusCache.has(prefName)
+ ? this.nimbusCache.get(prefName)
+ : this.cache.get(prefName);
+ },
+};
+
+/**
+ * Represents a name/value pair for a parameter
+ */
+class QueryParameter {
+ /**
+ * @param {string} name
+ * The parameter's name. Must not be null.
+ * @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 = null) {
+ 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 == lazy.SearchUtils.MOZ_PARAM.LOCALE) {
+ return Services.locale.requestedLocale;
+ }
+
+ // {moz:date}
+ if (name == lazy.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())
+ );
+ }
+ }
+
+ // 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.
+ */
+export class EngineURL {
+ params = [];
+ rels = [];
+
+ /**
+ * Creates an EngineURL.
+ *
+ * @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;
+
+ var templateURI = lazy.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":
+ 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
+ * The parameter to add.
+ * @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;
+ }
+
+ // Preference MozParams might not have a preferenced saved, or a valid value.
+ if (param.value != null) {
+ var value = ParamSubstitution(param.value, 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 searchTerms = "{" + USER_DEFINED + "}";
+ let paramName = this.params.find(p => p.value == searchTerms)?.name;
+ // Some query params might not be added to this.params
+ // in the engine construction process, so try checking the URL
+ // template for the existence of the query parameter value.
+ if (!paramName) {
+ let urlParms = new URL(this.template).searchParams;
+ for (let [name, value] of urlParms.entries()) {
+ if (value == searchTerms) {
+ paramName = name;
+ break;
+ }
+ }
+ }
+ return paramName ?? "";
+ }
+
+ _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 != lazy.SearchUtils.URL_TYPE.SEARCH) {
+ json.type = this.type;
+ }
+ if (this.method != "GET") {
+ json.method = this.method;
+ }
+
+ return json;
+ }
+}
+
+/**
+ * SearchEngine represents WebExtension based search engines.
+ */
+export 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).
+ #cachedSearchForm = null;
+ // Whether or not to send an attribution request to the server.
+ _sendAttributionRequest = 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;
+ /**
+ * The unique id of the Search Engine.
+ * The id is an UUID.
+ *
+ * @type {string}
+ */
+ #id;
+
+ /**
+ * Creates a Search Engine.
+ *
+ * @param {object} options
+ * The options for this search engine.
+ * @param {string} [options.id]
+ * The identifier to use for this engine, if none is specified a random
+ * uuid is created.
+ * @param {string} options.loadPath
+ * The path of the engine was originally loaded from. Should be anonymized.
+ */
+ constructor(options = {}) {
+ this.#id = options.id ?? this.#uuid();
+ if (!("loadPath" in options)) {
+ throw new Error("loadPath missing from options.");
+ }
+ this._loadPath = options.loadPath;
+ }
+
+ get _searchForm() {
+ return this.#cachedSearchForm;
+ }
+ set _searchForm(value) {
+ if (/^https?:/i.test(value)) {
+ this.#cachedSearchForm = value;
+ } else {
+ lazy.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 = lazy.SearchUtils.makeURI(iconURL);
+
+ // Ignore bad URIs
+ if (!uri) {
+ return;
+ }
+
+ lazy.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":
+ let iconLoadCallback = function (byteArray, contentType) {
+ // This callback may run after we've already set a preferred icon,
+ // so check again.
+ if (this._hasPreferredIcon && !isPreferred) {
+ return;
+ }
+
+ if (!byteArray) {
+ lazy.logConsole.warn("iconLoadCallback: load failed");
+ return;
+ }
+
+ if (byteArray.length > lazy.SearchUtils.MAX_ICON_SIZE) {
+ try {
+ lazy.logConsole.debug("iconLoadCallback: rescaling icon");
+ [byteArray, contentType] = rescaleIcon(byteArray, contentType);
+ } catch (ex) {
+ lazy.logConsole.error(
+ "Unable to set icon for the search engine:",
+ ex
+ );
+ return;
+ }
+ }
+
+ let dataURL =
+ "data:" +
+ contentType +
+ ";base64," +
+ btoa(String.fromCharCode.apply(null, byteArray));
+
+ this._iconURI = lazy.SearchUtils.makeURI(dataURL);
+
+ if (width && height) {
+ this._addIconToMap(width, height, dataURL);
+ }
+
+ if (this._engineAddedToStore) {
+ lazy.SearchUtils.notifyAction(
+ this,
+ lazy.SearchUtils.MODIFIED_TYPE.CHANGED
+ );
+ }
+ this._hasPreferredIcon = isPreferred;
+ };
+
+ let chan = lazy.SearchUtils.makeChannel(uri);
+ let listener = new lazy.SearchUtils.LoadListener(
+ chan,
+ /^image\//,
+ // 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.
+ *
+ * @param {object} details
+ * The details of the engine.
+ * @param {string} details.name
+ * The name of the engine.
+ * @param {string} details.keyword
+ * The keyword for the engine.
+ * @param {string} details.iconURL
+ * The url to use for the icon of the engine.
+ * @param {string} details.search_url
+ * The search url template for the engine.
+ * @param {string} [details.search_url_get_params]
+ * The search url parameters for use with the GET method.
+ * @param {string} [details.search_url_post_params]
+ * The search url parameters for use with the POST method.
+ * @param {object} [details.params]
+ * Any special Mozilla parameters.
+ * @param {string} [details.suggest_url]
+ * The suggestion url template for the engine.
+ * @param {string} [details.suggest_url_get_params]
+ * The suggestion url parameters for use with the GET method.
+ * @param {string} [details.suggest_url_post_params]
+ * The suggestion url parameters for use with the POST method.
+ * @param {string} [details.encoding]
+ * The encoding to use for the engine.
+ * @param {string} [details.search_form]
+ * THe search form url for the engine.
+ * @param {object} [configuration]
+ * The search engine configuration for application provided engines, that
+ * may be overriding some of the WebExtension's settings.
+ */
+ _initWithDetails(details, configuration = {}) {
+ this._orderHint = configuration.orderHint;
+ this._name = details.name.trim();
+ this._sendAttributionRequest =
+ configuration.sendAttributionRequest ?? false;
+
+ this._definedAliases = [];
+ if (Array.isArray(details.keyword)) {
+ this._definedAliases = details.keyword.map(k => k.trim());
+ } else if (details.keyword?.trim()) {
+ this._definedAliases = [details.keyword?.trim()];
+ }
+
+ this._description = details.description;
+ if (details.iconURL) {
+ this._setIcon(details.iconURL, true);
+ }
+ this._setUrls(details, 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} details
+ * The details of the engine.
+ * @param {string} details.search_url
+ * The search url template for the engine.
+ * @param {string} [details.search_url_get_params]
+ * The search url parameters for use with the GET method.
+ * @param {string} [details.search_url_post_params]
+ * The search url parameters for use with the POST method.
+ * @param {object} [details.params]
+ * Any special Mozilla parameters.
+ * @param {string} [details.suggest_url]
+ * The suggestion url template for the engine.
+ * @param {string} [details.suggest_url_get_params]
+ * The suggestion url parameters for use with the GET method.
+ * @param {string} [details.suggest_url_post_params]
+ * The suggestion url parameters for use with the POST method.
+ * @param {string} [details.encoding]
+ * The encoding to use for the engine.
+ * @param {string} [details.search_form]
+ * THe search form url for the engine.
+ * @param {object} [configuration]
+ * The search engine configuration for application provided engines, that
+ * may be overriding some of the WebExtension's settings.
+ */
+ _setUrls(details, configuration = {}) {
+ let postParams =
+ configuration.params?.searchUrlPostParams ||
+ details.search_url_post_params ||
+ "";
+ let url = this._getEngineURLFromMetaData(lazy.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(details.search_url),
+ getParams:
+ configuration.params?.searchUrlGetParams ||
+ details.search_url_get_params ||
+ "",
+ postParams,
+ mozParams: configuration.extraParams || details.params || [],
+ });
+
+ this._urls.push(url);
+
+ if (details.suggest_url) {
+ let suggestPostParams =
+ configuration.params?.suggestUrlPostParams ||
+ details.suggest_url_post_params ||
+ "";
+ url = this._getEngineURLFromMetaData(
+ lazy.SearchUtils.URL_TYPE.SUGGEST_JSON,
+ {
+ method: (suggestPostParams && "POST") || "GET",
+ // suggest_url doesn't currently get encoded.
+ template: details.suggest_url,
+ getParams:
+ configuration.params?.suggestUrlGetParams ||
+ details.suggest_url_get_params ||
+ "",
+ postParams: suggestPostParams,
+ mozParams: configuration.suggestExtraParams || [],
+ }
+ );
+
+ this._urls.push(url);
+ }
+
+ if (configuration?.urls?.trending) {
+ let trending = this._getEngineURLFromMetaData(
+ lazy.SearchUtils.URL_TYPE.TRENDING_JSON,
+ {
+ method: "GET",
+ template: decodeURI(configuration.urls.trending.fullPath),
+ getParams: configuration.urls.trending.query,
+ }
+ );
+ this._urls.push(trending);
+ }
+
+ if (details.encoding) {
+ this._queryCharset = details.encoding;
+ }
+ this.#cachedSearchForm = details.search_form;
+ }
+
+ checkSearchUrlMatchesManifest(details) {
+ let existingUrl = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH);
+
+ let newUrl = this._getEngineURLFromMetaData(
+ lazy.SearchUtils.URL_TYPE.SEARCH,
+ {
+ method: (details.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(details.search_url),
+ getParams: details.search_url_get_params || "",
+ postParams: details.search_url_post_params || "",
+ }
+ );
+
+ let existingSubmission = existingUrl.getSubmission("", this);
+ let newSubmission = newUrl.getSubmission("", this);
+
+ return (
+ existingSubmission.uri.equals(newSubmission.uri) &&
+ existingSubmission.postData == newSubmission.postData
+ );
+ }
+
+ /**
+ * 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.#cachedSearchForm,
+ };
+ this._urls = [];
+ this.setAttr("overriddenBy", extensionID);
+ this._setUrls(manifest.chrome_settings_overrides.search_provider);
+ lazy.SearchUtils.notifyAction(this, lazy.SearchUtils.MODIFIED_TYPE.CHANGED);
+ }
+
+ /**
+ * Resets the overrides for the engine if it has been overridden.
+ */
+ removeExtensionOverride() {
+ if (this.getAttr("overriddenBy")) {
+ // If the attribute is set, but there is no data, skip it. Worst case,
+ // the urls will be reset on a restart.
+ if (this._overriddenData) {
+ this._urls = this._overriddenData.urls;
+ this._queryCharset = this._overriddenData.queryCharset;
+ this.#cachedSearchForm = this._overriddenData.searchForm;
+ delete this._overriddenData;
+ } else {
+ lazy.logConsole.error(
+ `${this._name} had overriddenBy set, but no _overriddenData`
+ );
+ }
+ this.clearAttr("overriddenBy");
+ lazy.SearchUtils.notifyAction(
+ this,
+ lazy.SearchUtils.MODIFIED_TYPE.CHANGED
+ );
+ }
+ }
+
+ /**
+ * Init from a JSON record.
+ *
+ * @param {object} json
+ * The json record to use.
+ */
+ _initWithJSON(json) {
+ this.#id = json.id ?? this.#id;
+ this._name = json._name;
+ this._description = json.description;
+ this._hasPreferredIcon = json._hasPreferredIcon == undefined;
+ this._queryCharset =
+ json.queryCharset || lazy.SearchUtils.DEFAULT_QUERY_CHARSET;
+ this.#cachedSearchForm = json.__searchForm;
+ this._iconURI = lazy.SearchUtils.makeURI(json._iconURL);
+ this._iconMapObj = json._iconMapObj || null;
+ 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;
+
+ for (let i = 0; i < json._urls.length; ++i) {
+ let url = json._urls[i];
+ let engineURL = new EngineURL(
+ url.type || lazy.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() {
+ const fieldsToCopy = [
+ "id",
+ "_name",
+ "_loadPath",
+ "description",
+ "_iconURL",
+ "_iconMapObj",
+ "_metaData",
+ "_urls",
+ "_orderHint",
+ "_telemetryId",
+ "_filePath",
+ "_definedAliases",
+ ];
+
+ let json = {};
+ for (const field of fieldsToCopy) {
+ if (field in this) {
+ json[field] = this[field];
+ }
+ }
+
+ if (this.#cachedSearchForm) {
+ json.__searchForm = this.#cachedSearchForm;
+ }
+ if (!this._hasPreferredIcon) {
+ json._hasPreferredIcon = this._hasPreferredIcon;
+ }
+ if (this.queryCharset != lazy.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];
+ }
+
+ /**
+ * Get the user-defined alias.
+ *
+ * @type {string}
+ */
+ get alias() {
+ return this.getAttr("alias") || "";
+ }
+
+ set alias(val) {
+ var value = val ? val.trim() : "";
+ if (value != this.alias) {
+ this.setAttr("alias", value);
+ lazy.SearchUtils.notifyAction(
+ this,
+ lazy.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);
+ lazy.SearchUtils.notifyAction(
+ this,
+ lazy.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;
+ }
+
+ /**
+ * Whether or not this engine is provided by the application, e.g. it is
+ * in the list of configured search engines.
+ *
+ * @returns {boolean}
+ * This returns false for most engines, but may be overridden by particular
+ * engine types, such as add-on engines which are used by the application.
+ */
+ get isAppProvided() {
+ return false;
+ }
+
+ /**
+ * Whether or not this engine is an in-memory only search engine.
+ * These engines are typically application provided or policy engines,
+ * where they are loaded every time on SearchService initialization
+ * using the policy JSON or the extension manifest. Minimal details of the
+ * in-memory engines are saved to disk, but they are never loaded
+ * from the user's saved settings file.
+ *
+ * @returns {boolean}
+ * This results false for most engines, but may be overridden by particular
+ * engine types, such as add-on engines and policy engines.
+ */
+ get inMemory() {
+ return false;
+ }
+
+ get isGeneralPurposeEngine() {
+ return false;
+ }
+
+ get _hasUpdates() {
+ return false;
+ }
+
+ get name() {
+ return this._name;
+ }
+
+ /**
+ * The searchForm URL points to the engine's organic search page. This should
+ * not contain neither search term parameters nor partner codes, but may
+ * contain parameters which set the engine in the correct way.
+ *
+ * This URL is typically the prePath and filePath of the search submission URI,
+ * but may vary for different engines. For example, some engines may use a
+ * different domain, e.g. https://sub.example.com for the search URI but
+ * https://example.org/ for the organic search page.
+ *
+ * @returns {string}
+ */
+ get searchForm() {
+ // First look for a <Url rel="searchform">
+ var searchFormURL = this._getURLOfType(
+ lazy.SearchUtils.URL_TYPE.SEARCH,
+ "searchform"
+ );
+ if (searchFormURL) {
+ let submission = searchFormURL.getSubmission("", this);
+
+ // 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(lazy.SearchUtils.URL_TYPE.SEARCH);
+ if (!htmlUrl) {
+ throw Components.Exception(
+ "Engine has no HTML URL!",
+ Cr.NS_ERROR_UNEXPECTED
+ );
+ }
+ this._searchForm = lazy.SearchUtils.makeURI(htmlUrl.template).prePath;
+ }
+
+ return ParamSubstitution(this._searchForm, "", this);
+ }
+
+ get sendAttributionRequest() {
+ return this._sendAttributionRequest;
+ }
+
+ get queryCharset() {
+ return this._queryCharset || lazy.SearchUtils.DEFAULT_QUERY_CHARSET;
+ }
+
+ /**
+ * Gets an object that contains information about what to send to the search
+ * engine, for a request. This will be a URI and may also include data for POST
+ * requests.
+ *
+ * @param {string} searchTerms
+ * The search term(s) for the submission.
+ * Note: If an empty data string is supplied, the search form of the search
+ * engine will be returned. This is intentional, as in some cases on the current
+ * UI an empty search is intended to open the search engine's home/search page.
+ * @param {lazy.SearchUtils.URL_TYPE} [responseType]
+ * The MIME type that we'd like to receive in response
+ * to this submission. If null, will default to "text/html".
+ * @param {string} [purpose]
+ * A string that indicates the context of the search request. This may then
+ * be used to provide different submission data depending on the context.
+ * @returns {nsISearchSubmission|null}
+ * The submission data. If no appropriate submission can be determined for
+ * the request type, this may be null.
+ */
+ getSubmission(searchTerms, responseType, purpose) {
+ // We can't use a default parameter as that doesn't work correctly with
+ // the idl interfaces.
+ if (!responseType) {
+ responseType = lazy.SearchUtils.URL_TYPE.SEARCH;
+ }
+
+ var url = this._getURLOfType(responseType);
+
+ if (!url) {
+ return null;
+ }
+
+ if (
+ !searchTerms &&
+ responseType != lazy.SearchUtils.URL_TYPE.TRENDING_JSON
+ ) {
+ // Return a dummy submission object with our searchForm attribute
+ return new Submission(lazy.SearchUtils.makeURI(this.searchForm));
+ }
+
+ var submissionData = "";
+ try {
+ submissionData = Services.textToSubURI.ConvertAndEscape(
+ this.queryCharset,
+ searchTerms
+ );
+ } catch (ex) {
+ lazy.logConsole.warn(
+ "getSubmission: Falling back to default queryCharset!"
+ );
+ submissionData = Services.textToSubURI.ConvertAndEscape(
+ lazy.SearchUtils.DEFAULT_QUERY_CHARSET,
+ searchTerms
+ );
+ }
+ return url.getSubmission(submissionData, this, purpose);
+ }
+
+ /**
+ * Returns a search URL with no search terms. This is typically used for
+ * purposes where we want to check something on the URL, but not use it for
+ * an actual submission to the search engine.
+ *
+ * Note: getSubmission cannot be used for this case, as that returns the
+ * search form when passed an empty string.
+ *
+ * @returns {nsIURI}
+ */
+ get searchURLWithNoTerms() {
+ return this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH).getSubmission(
+ "",
+ this
+ ).uri;
+ }
+
+ /**
+ * Returns the search term of a possible search result URI if and only if:
+ * - The URI has the same scheme, host, and path as the engine.
+ * - All query parameters of the URI have a matching name and value in the engine.
+ * - An exception to the equality check is the engine's termsParameterName
+ * value, which contains a placeholder, i.e. {searchTerms}.
+ * - If an engine has query parameters with "null" values, they will be ignored.
+ *
+ * @param {nsIURI} uri
+ * A URI that may or may not be from a search result matching the engine.
+ *
+ * @returns {string}
+ * A string representing the termsParameterName value of the URI,
+ * or an empty string if the URI isn't matched to the engine.
+ */
+ searchTermFromResult(uri) {
+ let url = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH);
+ if (!url) {
+ return "";
+ }
+
+ // The engine URL and URI should have the same scheme, host, and path.
+ if (
+ uri.spec.split("?")[0].toLowerCase() !=
+ url.template.split("?")[0].toLowerCase()
+ ) {
+ return "";
+ }
+
+ let engineParams;
+ if (url.params.length) {
+ engineParams = new URLSearchParams();
+ for (let { name, value } of url.params) {
+ // Some values might be null, so avoid adding
+ // them since the input is unlikely to have it too.
+ if (value) {
+ // Use append() rather than set() so multiple
+ // values of the same name can be stored.
+ engineParams.append(name, value);
+ }
+ }
+ } else {
+ // Try checking the template for the presence of query params.
+ engineParams = new URL(url.template).searchParams;
+ }
+
+ let uriParams = new URLSearchParams(uri.query);
+ if (
+ new Set([...uriParams.keys()]).size !=
+ new Set([...engineParams.keys()]).size
+ ) {
+ return "";
+ }
+
+ let termsParameterName = this.getURLParsingInfo().termsParameterName;
+ for (let [name, value] of uriParams.entries()) {
+ // Don't check the name matching the search
+ // query because its value will differ.
+ if (name == termsParameterName) {
+ continue;
+ }
+ // All params of an input must have a matching
+ // key and value in the list of engine parameters.
+ if (!engineParams.getAll(name).includes(value)) {
+ return "";
+ }
+ }
+
+ // An engine can use a non UTF-8 charset, which URLSearchParams
+ // might not parse properly. Convert the terms parameter value
+ // from the original input using the appropriate charset.
+ if (this.queryCharset.toLowerCase() != "utf-8") {
+ let name = `${termsParameterName}=`;
+ let queryString = uri.query
+ .split("&")
+ .filter(str => str.startsWith(name))
+ .pop();
+ return Services.textToSubURI.UnEscapeAndConvert(
+ this.queryCharset,
+ queryString.substring(queryString.indexOf("=") + 1).replace(/\+/g, " ")
+ );
+ }
+
+ return uriParams.get(termsParameterName);
+ }
+
+ get searchUrlQueryParamName() {
+ if (this._searchUrlQueryParamName != null) {
+ return this._searchUrlQueryParamName;
+ }
+
+ let submission = this.getSubmission(
+ "{searchTerms}",
+ lazy.SearchUtils.URL_TYPE.SEARCH
+ );
+
+ if (submission.postData) {
+ console.error("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 searchURLPublicSuffix = Services.eTLD.getKnownPublicSuffix(
+ this.searchURLWithNoTerms
+ );
+ return (this._searchUrlPublicSuffix = searchURLPublicSuffix);
+ }
+
+ // from nsISearchEngine
+ supportsResponseType(type) {
+ return this._getURLOfType(type) != null;
+ }
+
+ // from nsISearchEngine
+ get searchUrlDomain() {
+ let url = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH);
+ if (url) {
+ return url.templateHost;
+ }
+ return "";
+ }
+
+ /**
+ * @returns {object}
+ * URL parsing properties used by _buildParseSubmissionMap.
+ */
+ getURLParsingInfo() {
+ let url = this._getURLOfType(lazy.SearchUtils.URL_TYPE.SEARCH);
+ if (!url || url.method != "GET") {
+ return null;
+ }
+
+ let termsParameterName = url._getTermsParameterName();
+ if (!termsParameterName) {
+ return null;
+ }
+
+ let templateUrl = Services.io.newURI(url.template);
+ 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
+ * The options object
+ * @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
+ * elements
+ */
+ speculativeConnect(options) {
+ if (!options || !options.window) {
+ console.error(
+ "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.searchURLWithNoTerms;
+
+ 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, false);
+ } catch (e) {
+ // Can't setup speculative connection for this url, just ignore it.
+ console.error(e);
+ }
+
+ if (this.supportsResponseType(lazy.SearchUtils.URL_TYPE.SUGGEST_JSON)) {
+ let suggestURI = this.getSubmission(
+ "dummy",
+ lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
+ ).uri;
+ if (suggestURI.prePath != searchURI.prePath) {
+ try {
+ connector.speculativeConnect(suggestURI, principal, callbacks, false);
+ } catch (e) {
+ // Can't setup speculative connection for this url, just ignore it.
+ console.error(e);
+ }
+ }
+ }
+ }
+
+ get id() {
+ return this.#id;
+ }
+
+ /**
+ * Generates an UUID.
+ *
+ * @returns {string}
+ * An UUID string, without leading or trailing braces.
+ */
+ #uuid() {
+ let uuid = Services.uuid.generateUUID().toString();
+ return uuid.slice(1, uuid.length - 1);
+ }
+}
+
+/**
+ * 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;
+ }
+}
diff --git a/toolkit/components/search/SearchEngineSelector.sys.mjs b/toolkit/components/search/SearchEngineSelector.sys.mjs
new file mode 100644
index 0000000000..962b9cb3df
--- /dev/null
+++ b/toolkit/components/search/SearchEngineSelector.sys.mjs
@@ -0,0 +1,447 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+const USER_LOCALE = "$USER_LOCALE";
+const USER_REGION = "$USER_REGION";
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "SearchEngineSelector",
+ maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
+ });
+});
+
+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.
+ */
+export class SearchEngineSelector {
+ /**
+ * @param {Function} listener
+ * A listener for configuration update changes.
+ */
+ constructor(listener) {
+ this.QueryInterface = ChromeUtils.generateQI(["nsIObserver"]);
+ this._remoteConfig = lazy.RemoteSettings(lazy.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) {
+ lazy.logConsole.error(ex);
+ failed = true;
+ }
+ if (!result.length) {
+ lazy.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.
+ *
+ * @param {object} options
+ * The options object
+ * @param {object} options.data
+ * The data to update
+ * @param {Array} options.data.current
+ * The new configuration object
+ */
+ _onConfigurationUpdated({ data: { current } }) {
+ this._configuration = current;
+ lazy.logConsole.debug("Search configuration updated remotely");
+ if (this._changeListener) {
+ this._changeListener();
+ }
+ }
+
+ /**
+ * @param {object} options
+ * The options object
+ * @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.
+ * @param {string} [options.name]
+ * The name of the application.
+ * @param {string} [options.version]
+ * The version of the application.
+ * @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,
+ name = Services.appinfo.name ?? "",
+ version = Services.appinfo.version ?? "",
+ }) {
+ if (!this._configuration) {
+ await this.getEngineConfiguration();
+ }
+ lazy.logConsole.debug(
+ `fetchEngineConfiguration ${locale}:${region}:${channel}:${distroID}:${experiment}:${name}:${version}`
+ );
+ let engines = [];
+ const lcName = name.toLowerCase();
+ const lcVersion = version.toLowerCase();
+ 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;
+ }
+ }
+
+ let shouldInclude = () => {
+ 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;
+ };
+
+ const distroExcluded =
+ (distroID &&
+ sectionIncludes(section, "excludedDistributions", distroID)) ||
+ isDistroExcluded(section, "distributions", distroID);
+
+ if (distroID && !distroExcluded && section.override) {
+ if ("included" in section || "excluded" in section) {
+ return shouldInclude();
+ }
+ return true;
+ }
+
+ if (
+ sectionExcludes(section, "channel", channel) ||
+ sectionExcludes(section, "name", lcName) ||
+ distroExcluded ||
+ belowMinVersion(section, lcVersion) ||
+ aboveMaxVersion(section, lcVersion)
+ ) {
+ return false;
+ }
+ return shouldInclude();
+ });
+
+ 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
+ .replace(USER_LOCALE, locale)
+ .replace(USER_REGION, lcRegion);
+ engines.push(engine);
+ }
+ } else {
+ const engine = { ...baseConfig };
+ (engine.webExtension = engine.webExtension || {}).locale =
+ lazy.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) {
+ engine.telemetryId = engine.telemetryId
+ ?.replace(USER_LOCALE, locale)
+ .replace(USER_REGION, lcRegion);
+ 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 (lazy.SearchUtils.loggingEnabled) {
+ lazy.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.sys.mjs b/toolkit/components/search/SearchService.sys.mjs
new file mode 100644
index 0000000000..afd0fff703
--- /dev/null
+++ b/toolkit/components/search/SearchService.sys.mjs
@@ -0,0 +1,3699 @@
+/* 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 */
+
+import { PromiseUtils } from "resource://gre/modules/PromiseUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonSearchEngine: "resource://gre/modules/AddonSearchEngine.sys.mjs",
+ IgnoreLists: "resource://gre/modules/IgnoreLists.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ OpenSearchEngine: "resource://gre/modules/OpenSearchEngine.sys.mjs",
+ PolicySearchEngine: "resource://gre/modules/PolicySearchEngine.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchEngine: "resource://gre/modules/SearchEngine.sys.mjs",
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+ SearchSettings: "resource://gre/modules/SearchSettings.sys.mjs",
+ SearchStaticData: "resource://gre/modules/SearchStaticData.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ UserSearchEngine: "resource://gre/modules/UserSearchEngine.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "SearchService",
+ maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
+ });
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "timerManager",
+ "@mozilla.org/updates/timer-manager;1",
+ "nsIUpdateTimerManager"
+);
+
+const TOPIC_LOCALES_CHANGE = "intl:app-locales-changed";
+const QUIT_APPLICATION_TOPIC = "quit-application";
+
+// The update timer for OpenSearch engines checks in once a day.
+const OPENSEARCH_UPDATE_TIMER_TOPIC = "search-engine-update-timer";
+const OPENSEARCH_UPDATE_TIMER_INTERVAL = 60 * 60 * 24;
+
+// The default engine update interval, in days. This is only used if an engine
+// specifies an updateURL, but not an updateInterval.
+const OPENSEARCH_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;
+
+/**
+ * A reason that is used in the change of default search engine event telemetry.
+ * These are mutally exclusive.
+ */
+const REASON_CHANGE_MAP = new Map([
+ // The cause of the change is unknown.
+ [Ci.nsISearchService.CHANGE_REASON_UNKNOWN, "unknown"],
+ // The user changed the default search engine via the options in the
+ // preferences UI.
+ [Ci.nsISearchService.CHANGE_REASON_USER, "user"],
+ // The change resulted from the user toggling the "Use this search engine in
+ // Private Windows" option in the preferences UI.
+ [Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_SPLIT, "user_private_split"],
+ // The user changed the default via keys (cmd/ctrl-up/down) in the separate
+ // search bar.
+ [Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR, "user_searchbar"],
+ // The user changed the default via context menu on the one-off buttons in the
+ // separate search bar.
+ [
+ Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT,
+ "user_searchbar_context",
+ ],
+ // An add-on requested the change of default on install, which was either
+ // accepted automatically or by the user.
+ [Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL, "addon-install"],
+ // An add-on was uninstalled, which caused the engine to be uninstalled.
+ [Ci.nsISearchService.CHANGE_REASON_ADDON_UNINSTALL, "addon-uninstall"],
+ // A configuration update caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_CONFIG, "config"],
+ // A locale update caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_LOCALE, "locale"],
+ // A region update caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_REGION, "region"],
+ // Turning on/off an experiment caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_EXPERIMENT, "experiment"],
+ // An enterprise policy caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_ENTERPRISE, "enterprise"],
+ // The UI Tour caused a change of default.
+ [Ci.nsISearchService.CHANGE_REASON_UITOUR, "uitour"],
+]);
+
+/**
+ * The ParseSubmissionResult contains getter methods that return attributes
+ * about the parsed submission url.
+ *
+ * @implements {nsIParseSubmissionResult}
+ */
+class ParseSubmissionResult {
+ constructor(engine, terms, termsParameterName) {
+ this.#engine = engine;
+ this.#terms = terms;
+ this.#termsParameterName = termsParameterName;
+ }
+
+ get engine() {
+ return this.#engine;
+ }
+
+ get terms() {
+ return this.#terms;
+ }
+
+ get termsParameterName() {
+ return this.#termsParameterName;
+ }
+
+ /**
+ * The search engine associated with the URL passed in to
+ * nsISearchEngine::parseSubmissionURL, or null if the URL does not represent
+ * a search submission.
+ *
+ * @type {nsISearchEngine|null}
+ */
+ #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.*
+ *
+ * @type {string}
+ */
+ #terms;
+
+ /**
+ * The name of the query parameter used by `engine` for queries. E.g. "q".
+ *
+ * @type {string}
+ */
+ #termsParameterName;
+
+ QueryInterface = ChromeUtils.generateQI(["nsISearchParseSubmissionResult"]);
+}
+
+const gEmptyParseSubmissionResult = Object.freeze(
+ new ParseSubmissionResult(null, "", "")
+);
+
+/**
+ * The search service handles loading and maintaining of search engines. It will
+ * also work out the default lists for each locale/region.
+ *
+ * @implements {nsISearchService}
+ */
+export class SearchService {
+ constructor() {
+ this.#initObservers = PromiseUtils.defer();
+ // this._engines is prefixed with _ rather than # because it is called from
+ // a test.
+ this._engines = new Map();
+ this._settings = new lazy.SearchSettings(this);
+ }
+
+ classID = Components.ID("{7319788a-fe93-4db3-9f39-818cf08f4256}");
+
+ get defaultEngine() {
+ this.#ensureInitialized();
+ return this._getEngineDefault(false);
+ }
+
+ set defaultEngine(newEngine) {
+ this.#ensureInitialized();
+ this.#setEngineDefault(false, newEngine);
+ }
+
+ get defaultPrivateEngine() {
+ this.#ensureInitialized();
+ return this._getEngineDefault(this.#separatePrivateDefault);
+ }
+
+ set defaultPrivateEngine(newEngine) {
+ this.#ensureInitialized();
+ if (!this._separatePrivateDefaultPrefValue) {
+ Services.prefs.setBoolPref(
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+ }
+ this.#setEngineDefault(this.#separatePrivateDefault, newEngine);
+ }
+
+ async getDefault() {
+ await this.init();
+ return this.defaultEngine;
+ }
+
+ async setDefault(engine, changeSource) {
+ await this.init();
+ this.#setEngineDefault(false, engine, changeSource);
+ }
+
+ async getDefaultPrivate() {
+ await this.init();
+ return this.defaultPrivateEngine;
+ }
+
+ async setDefaultPrivate(engine, changeSource) {
+ await this.init();
+ if (!this._separatePrivateDefaultPrefValue) {
+ Services.prefs.setBoolPref(
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+ }
+ this.#setEngineDefault(this.#separatePrivateDefault, engine, changeSource);
+ }
+
+ /**
+ * @returns {SearchEngine}
+ * The engine that is the default for this locale/region, ignoring any
+ * user changes to the default engine.
+ */
+ get appDefaultEngine() {
+ return this.#appDefaultEngine();
+ }
+
+ /**
+ * @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 appPrivateDefaultEngine() {
+ return this.#appDefaultEngine(this.#separatePrivateDefault);
+ }
+
+ /**
+ * 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, use hasSuccessfullyInitialized() for that.
+ *
+ * @returns {boolean}
+ * |true | if the search service has finished its attempt to initialize and
+ * we have an outcome. It could have failed or succeeded during this
+ * process.
+ * |false| if initialization has not been triggered yet or initialization is
+ * still ongoing.
+ */
+ get isInitialized() {
+ return (
+ this.#initializationStatus == "success" ||
+ this.#initializationStatus == "failed"
+ );
+ }
+
+ /**
+ * Determine whether initialization has been successfully completed.
+ *
+ * @returns {boolean}
+ * |true | if the search service has succesfully initialized.
+ * |false| if initialization has not been started yet, initialization is
+ * still ongoing or initializaiton has failed.
+ */
+ get hasSuccessfullyInitialized() {
+ return this.#initializationStatus == "success";
+ }
+
+ getDefaultEngineInfo() {
+ let [telemetryId, defaultSearchEngineData] = this.#getEngineInfo(
+ this.defaultEngine
+ );
+ const result = {
+ defaultSearchEngine: telemetryId,
+ defaultSearchEngineData,
+ };
+
+ if (this.#separatePrivateDefault) {
+ let [privateTelemetryId, defaultPrivateSearchEngineData] =
+ this.#getEngineInfo(this.defaultPrivateEngine);
+ result.defaultPrivateSearchEngine = privateTelemetryId;
+ result.defaultPrivateSearchEngineData = defaultPrivateSearchEngineData;
+ }
+
+ return result;
+ }
+
+ /**
+ * If possible, please call getEngineById() rather than getEngineByName()
+ * because engines are stored as { id: object } in this._engine Map.
+ *
+ * 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.#getEngineByName(engineName);
+ }
+
+ /**
+ * Returns the engine associated with the name without initialization checks.
+ *
+ * @param {string} engineName
+ * The name of the engine.
+ * @returns {SearchEngine}
+ * The associated engine if found, null otherwise.
+ */
+ #getEngineByName(engineName) {
+ for (let engine of this._engines.values()) {
+ if (engine.name == engineName) {
+ return engine;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the engine associated with the id.
+ *
+ * @param {string} engineId
+ * The id of the engine.
+ * @returns {SearchEngine}
+ * The associated engine if found, null otherwise.
+ */
+ getEngineById(engineId) {
+ this.#ensureInitialized();
+ return this._engines.get(engineId) || null;
+ }
+
+ async getEngineByAlias(alias) {
+ await this.init();
+ for (var engine of this._engines.values()) {
+ if (engine && engine.aliases.includes(alias)) {
+ return engine;
+ }
+ }
+ return null;
+ }
+
+ async getEngines() {
+ await this.init();
+ lazy.logConsole.debug("getEngines: getting all engines");
+ return this.#sortedEngines;
+ }
+
+ async getVisibleEngines() {
+ await this.init(true);
+ lazy.logConsole.debug("getVisibleEngines: getting all visible engines");
+ return this.#sortedVisibleEngines;
+ }
+
+ async getAppProvidedEngines() {
+ await this.init();
+
+ return this._sortEnginesByDefaults(
+ this.#sortedEngines.filter(e => e.isAppProvided)
+ );
+ }
+
+ async getEnginesByExtensionID(extensionID) {
+ await this.init();
+ return this.#getEnginesByExtensionID(extensionID);
+ }
+
+ // nsISearchService
+ async init() {
+ if (this.#initStarted) {
+ return this.#initObservers.promise;
+ }
+ lazy.logConsole.debug("init");
+
+ TelemetryStopwatch.start("SEARCH_SERVICE_INIT_MS");
+ const timerId = Glean.searchService.startupTime.start();
+ this.#initStarted = true;
+ let result;
+ try {
+ // Complete initialization by calling asynchronous initializer.
+ result = await this.#init();
+ TelemetryStopwatch.finish("SEARCH_SERVICE_INIT_MS");
+ Glean.searchService.startupTime.stopAndAccumulate(timerId);
+ } catch (ex) {
+ this.#initializationStatus = "failed";
+ TelemetryStopwatch.cancel("SEARCH_SERVICE_INIT_MS");
+ Glean.searchService.startupTime.cancel(timerId);
+ this.#initObservers.reject(ex.result);
+ throw ex;
+ }
+
+ if (!Components.isSuccessCode(result)) {
+ throw new Error("SearchService failed while it was initializing.");
+ } 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.
+ lazy.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 Cr.NS_OK;
+ }
+
+ /**
+ * Runs background checks for the search service. This is called from
+ * BrowserGlue and may be run once per session if the user is idle for
+ * long enough.
+ */
+ async runBackgroundChecks() {
+ await this.init();
+ await this.#migrateLegacyEngines();
+ await this.#checkWebExtensionEngines();
+ await this.#addOpenSearchTelemetry();
+ }
+
+ /**
+ * Test only - reset SearchService data. Ideally this should be replaced
+ */
+ reset() {
+ this.#initializationStatus = "not initialized";
+ this.#initObservers = PromiseUtils.defer();
+ this.#initStarted = false;
+ this.#startupExtensions = new Set();
+ this._engines.clear();
+ this._cachedSortedEngines = null;
+ this.#currentEngine = null;
+ this.#currentPrivateEngine = null;
+ this._searchDefault = null;
+ this.#searchPrivateDefault = null;
+ this.#maybeReloadDebounce = false;
+ this._settings._batchTask?.disarm();
+ }
+
+ // Test-only function to set SearchService initialization status
+ forceInitializationStatusForTests(status) {
+ this.#initializationStatus = status;
+ }
+
+ // Test-only function to reset just the engine selector so that it can
+ // load a different configuration.
+ resetEngineSelector() {
+ this.#engineSelector = new lazy.SearchEngineSelector(
+ this.#handleConfigurationUpdated.bind(this)
+ );
+ }
+
+ resetToAppDefaultEngine() {
+ let appDefaultEngine = this.appDefaultEngine;
+ appDefaultEngine.hidden = false;
+ this.defaultEngine = appDefaultEngine;
+ }
+
+ async maybeSetAndOverrideDefault(extension) {
+ let searchProvider =
+ extension.manifest.chrome_settings_overrides.search_provider;
+ let engine = this.getEngineByName(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
+ ))
+ ) {
+ lazy.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);
+ lazy.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);
+ lazy.logConsole.debug(
+ "Re-enabling overriding of core extension by",
+ extension.id
+ );
+ return {
+ canChangeToAppProvided: true,
+ canInstallEngine: false,
+ };
+ }
+
+ return {
+ canChangeToAppProvided: false,
+ canInstallEngine: false,
+ };
+ }
+
+ /**
+ * 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) {
+ let newEngine = new lazy.PolicySearchEngine({ details });
+ let existingEngine = this.#getEngineByName(newEngine.name);
+ if (existingEngine) {
+ throw Components.Exception(
+ "An engine with that name already exists!",
+ Cr.NS_ERROR_FILE_ALREADY_EXISTS
+ );
+ }
+ lazy.logConsole.debug("Adding Policy Engine:", newEngine.name);
+ this.#addEngineToStore(newEngine);
+ }
+
+ /**
+ * Adds a search engine that is specified by the user.
+ *
+ * @param {string} name
+ * The name of the search engine
+ * @param {string} url
+ * The url that the search engine uses for searches
+ * @param {string} alias
+ * An alias for the search engine
+ */
+ async addUserEngine(name, url, alias) {
+ await this.init();
+
+ let newEngine = new lazy.UserSearchEngine({
+ details: { name, url, alias },
+ });
+ let existingEngine = this.#getEngineByName(newEngine.name);
+ if (existingEngine) {
+ throw Components.Exception(
+ "An engine with that name already exists!",
+ Cr.NS_ERROR_FILE_ALREADY_EXISTS
+ );
+ }
+ lazy.logConsole.debug(`Adding ${newEngine.name}`);
+ this.#addEngineToStore(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) {
+ lazy.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"
+ ) {
+ // Bug 1679861 An a upgrade or downgrade could be adding a search engine
+ // that was not in a prior version, or the addon may have been blocklisted.
+ // In either case, there will not be an existing engine.
+ let existing = await this.#upgradeExtensionEngine(extension);
+ if (existing?.length) {
+ return existing;
+ }
+ }
+
+ 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.isInitialized && !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)
+ );
+ }
+ }
+ lazy.logConsole.debug(
+ "addEnginesFromExtension: Ignoring builtIn engine."
+ );
+ return [];
+ }
+
+ // If we havent started SearchService yet, store this extension
+ // to install in SearchService.init().
+ if (!this.isInitialized) {
+ this.#startupExtensions.add(extension);
+ return [];
+ }
+
+ return this.#installExtensionEngine(extension, [
+ lazy.SearchUtils.DEFAULT_TAG,
+ ]);
+ }
+
+ async addOpenSearchEngine(engineURL, iconURL) {
+ lazy.logConsole.debug("addEngine: Adding", engineURL);
+ await this.init();
+ let errCode;
+ try {
+ var engine = new lazy.OpenSearchEngine();
+ engine._setIcon(iconURL, false);
+ errCode = await new Promise(resolve => {
+ engine.install(engineURL, errorCode => {
+ resolve(errorCode);
+ });
+ });
+ if (errCode) {
+ throw errCode;
+ }
+ } catch (ex) {
+ throw Components.Exception(
+ "addEngine: Error adding engine:\n" + ex,
+ errCode || Cr.NS_ERROR_FAILURE
+ );
+ }
+ this.#maybeStartOpenSearchUpdateTimer();
+ return engine;
+ }
+
+ async removeWebExtensionEngine(id) {
+ if (!this.isInitialized) {
+ lazy.logConsole.debug(
+ "Delaying removing extension engine on startup:",
+ id
+ );
+ this.#startupRemovedExtensions.add(id);
+ return;
+ }
+
+ lazy.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
+ );
+ }
+
+ engineToRemove.pendingRemoval = true;
+
+ if (engineToRemove == this.defaultEngine) {
+ this.#findAndSetNewDefaultEngine({
+ privateMode: false,
+ });
+ }
+
+ // 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.#findAndSetNewDefaultEngine({
+ privateMode: true,
+ });
+ }
+
+ if (engineToRemove.inMemory) {
+ // 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;
+ engineToRemove.pendingRemoval = false;
+ } 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();
+ }
+ }
+ lazy.SearchUtils.notifyAction(
+ engineToRemove,
+ lazy.SearchUtils.MODIFIED_TYPE.REMOVED
+ );
+ }
+
+ 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 lazy.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.#sortedVisibleEngines[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._cachedSortedEngines.splice(currentIndex, 1)[0];
+ this._cachedSortedEngines.splice(newIndex, 0, movedEngine);
+
+ lazy.SearchUtils.notifyAction(
+ engine,
+ lazy.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;
+ }
+ }
+ }
+
+ parseSubmissionURL(url) {
+ if (!this.hasSuccessfullyInitialized) {
+ // If search is not initialized or failed initializing, 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);
+
+ // Exclude any URL that is not HTTP or HTTPS from the beginning.
+ if (soughtUrl.schemeIs("http") && soughtUrl.schemeIs("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".
+ // We cannot use `URLSearchParams` here as the terms might not be
+ // encoded in UTF-8.
+ 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;
+ }
+
+ // 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;
+ }
+
+ return new ParseSubmissionResult(
+ mapEntry.engine,
+ terms,
+ mapEntry.termsParameterName
+ );
+ }
+
+ /**
+ * This is a nsITimerCallback for the timerManager notification that is
+ * registered for handling updates to search engines. Only OpenSearch engines
+ * have these updates and hence, only those are handled here.
+ */
+ notify() {
+ lazy.logConsole.debug("notify: checking for updates");
+
+ // Walk the engine list, looking for engines whose update time has expired.
+ var currentTime = Date.now();
+ lazy.logConsole.debug("currentTime:" + currentTime);
+ for (let engine of this._engines.values()) {
+ if (!(engine instanceof lazy.OpenSearchEngine && engine._hasUpdates)) {
+ continue;
+ }
+
+ var expirTime = engine.getAttr("updateexpir");
+ lazy.logConsole.debug(
+ engine.name,
+ "expirTime:",
+ expirTime,
+ "updateURL:",
+ engine._updateURL,
+ "iconUpdateURL:",
+ engine._iconUpdateURL
+ );
+
+ var engineExpired = expirTime <= currentTime;
+
+ if (!expirTime || !engineExpired) {
+ lazy.logConsole.debug("skipping engine");
+ continue;
+ }
+
+ lazy.logConsole.debug(engine.name, "has expired");
+
+ engineUpdateService.update(engine);
+
+ // Schedule the next update
+ engineUpdateService.scheduleNextUpdate(engine);
+ } // end engine iteration
+ }
+
+ #initObservers;
+ #currentEngine;
+ #currentPrivateEngine;
+ #queuedIdle;
+
+ /**
+ * Indicates that the initialization has started or not.
+ *
+ * @type {boolean}
+ */
+ #initStarted = false;
+
+ /**
+ * Indicates if initialization has failed, succeeded or has not finished yet.
+ *
+ * There are 3 possible statuses:
+ * "not initialized" - The SearchService has not finished initialization.
+ * "success" - The SearchService successfully completed initialization.
+ * "failed" - The SearchService failed during initialization.
+ *
+ * @type {string}
+ */
+ #initializationStatus = "not initialized";
+
+ /**
+ * Indicates if we're already waiting for maybeReloadEngines to be called.
+ *
+ * @type {boolean}
+ */
+ #maybeReloadDebounce = false;
+
+ /**
+ * Indicates if we're currently in maybeReloadEngines.
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in a test.
+ *
+ * @type {boolean}
+ */
+ _reloadingEngines = false;
+
+ /**
+ * The engine selector singleton that is managing the engine configuration.
+ *
+ * @type {SearchEngineSelector|null}
+ */
+ #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.
+ *
+ * @type {Array}
+ */
+ #submissionURLIgnoreList = [];
+
+ /**
+ * Various search engines may be ignored if their load path is contained
+ * in this list. The list is controlled via remote settings.
+ *
+ * @type {Array}
+ */
+ #loadPathIgnoreList = [];
+
+ /**
+ * A map of engine display names to `SearchEngine`.
+ *
+ * @type {Map<string, object>|null}
+ */
+ _engines = null;
+
+ /**
+ * An array of engine short names sorted into display order.
+ *
+ * @type {Array}
+ */
+ _cachedSortedEngines = null;
+
+ /**
+ * A flag to prevent setting of useSavedOrder when there's non-user
+ * activity happening.
+ *
+ * @type {boolean}
+ */
+ #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.
+ *
+ * @type {object}
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in a test.
+ */
+ _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.
+ *
+ * @type {object}
+ */
+ #searchPrivateDefault = null;
+
+ /**
+ * A Set of installed search extensions reported by AddonManager
+ * startup before SearchSevice has started. Will be installed
+ * during init().
+ *
+ * @type {Set<object>}
+ */
+ #startupExtensions = new Set();
+
+ /**
+ * A Set of removed search extensions reported by AddonManager
+ * startup before SearchSevice has started. Will be removed
+ * during init().
+ *
+ * @type {Set<object>}
+ */
+ #startupRemovedExtensions = new Set();
+
+ /**
+ * A reference to the handler for the default override allow list.
+ *
+ * @type {SearchDefaultOverrideAllowlistHandler|null}
+ */
+ #defaultOverrideAllowlist = null;
+
+ /**
+ * 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;
+
+ /**
+ * Keep track of observers have been added.
+ *
+ * @type {boolean}
+ */
+ #observersAdded = false;
+
+ /**
+ * Keeps track to see if the OpenSearch update timer has been started or not.
+ *
+ * @type {boolean}
+ */
+ #openSearchUpdateTimerStarted = false;
+
+ get #sortedEngines() {
+ if (!this._cachedSortedEngines) {
+ return this.#buildSortedEngineList();
+ }
+ return this._cachedSortedEngines;
+ }
+ /**
+ * 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.
+ *
+ * @returns {boolean}
+ */
+ get #separatePrivateDefault() {
+ return (
+ this._separatePrivateDefaultPrefValue &&
+ this._separatePrivateDefaultEnabledPrefValue
+ );
+ }
+
+ #getEnginesByExtensionID(extensionID) {
+ lazy.logConsole.debug("getEngines: getting all engines for", extensionID);
+ var engines = this.#sortedEngines.filter(function (engine) {
+ return engine._extensionID == extensionID;
+ });
+ return engines;
+ }
+
+ /**
+ * Returns the engine associated with the WebExtension details.
+ *
+ * @param {object} details
+ * Details of the WebExtension.
+ * @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;
+ }
+
+ /**
+ * Helper function to get the current default engine.
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in test_remove_engine_notification_box.js
+ *
+ * @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) {
+ let currentEngine = privateMode
+ ? this.#currentPrivateEngine
+ : this.#currentEngine;
+
+ if (currentEngine && !currentEngine.hidden) {
+ return currentEngine;
+ }
+
+ // No default loaded, so find it from settings.
+ const attributeName = privateMode
+ ? "privateDefaultEngineId"
+ : "defaultEngineId";
+
+ let engineId = this._settings.getMetaDataAttribute(attributeName);
+ let engine = this._engines.get(engineId) || null;
+ // If the selected engine is an application provided one, we can relax the
+ // verification hash check to reduce the annoyance for users who
+ // backup/sync their profile in custom ways.
+ if (
+ engine &&
+ (engine.isAppProvided ||
+ this._settings.getVerifiedMetaDataAttribute(attributeName))
+ ) {
+ if (privateMode) {
+ this.#currentPrivateEngine = engine;
+ } else {
+ this.#currentEngine = engine;
+ }
+ }
+ if (!engineId) {
+ if (privateMode) {
+ this.#currentPrivateEngine = this.appPrivateDefaultEngine;
+ } else {
+ this.#currentEngine = this.appDefaultEngine;
+ }
+ }
+
+ currentEngine = privateMode
+ ? this.#currentPrivateEngine
+ : this.#currentEngine;
+ if (currentEngine && !currentEngine.hidden) {
+ return currentEngine;
+ }
+ // No default in settings or it is hidden, so find the new default.
+ return this.#findAndSetNewDefaultEngine({ privateMode });
+ }
+
+ /**
+ * If initialization has not been completed yet, perform synchronous
+ * initialization.
+ * Throws in case of initialization error.
+ */
+ #ensureInitialized() {
+ if (this.#initializationStatus === "success") {
+ return;
+ }
+
+ if (this.#initializationStatus === "failed") {
+ throw new Error("SearchService failed while it was initializing.");
+ }
+
+ // This Error is thrown when this.#initializationStatus is
+ // "not initialized" because it is in the middle of initialization and
+ // hasn't finished or hasn't started.
+ let err = new Error(
+ "Something tried to use the search service before it finished " +
+ "initializing. 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",
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ false,
+ this.#onSeparateDefaultPrefChanged.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_separatePrivateDefaultEnabledPrefValue",
+ lazy.SearchUtils.BROWSER_SEARCH_PREF +
+ "separatePrivateDefault.ui.enabled",
+ false,
+ this.#onSeparateDefaultPrefChanged.bind(this)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "separatePrivateDefaultUrlbarResultEnabled",
+ lazy.SearchUtils.BROWSER_SEARCH_PREF +
+ "separatePrivateDefault.urlbarResult.enabled",
+ false
+ );
+
+ // We need to catch the region being updated
+ // during initialisation so we start listening
+ // straight away.
+ Services.obs.addObserver(this, lazy.Region.REGION_TOPIC);
+
+ let result = Cr.NS_OK;
+ try {
+ // Create the search engine selector.
+ this.#engineSelector = new lazy.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(console.error);
+
+ 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) {
+ lazy.logConsole.warn("#init: abandoning init due to shutting down");
+ this.#initializationStatus = "failed";
+ this.#initObservers.reject(Cr.NS_ERROR_ABORT);
+ return Cr.NS_ERROR_ABORT;
+ }
+
+ // Make sure the current list of engines is persisted, without the need to wait.
+ lazy.logConsole.debug("#init: engines loaded, writing settings");
+ this.#initializationStatus = "success";
+ this.#addObservers();
+ this.#initObservers.resolve(result);
+ } catch (error) {
+ this.#initializationStatus = "failed";
+ result = error.result || Cr.NS_ERROR_FAILURE;
+
+ lazy.logConsole.error("#init: failure initializing search:", error);
+ this.#initObservers.reject(result);
+ }
+
+ this.#recordTelemetryData();
+
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "init-complete"
+ );
+
+ lazy.logConsole.debug("Completed #init");
+
+ // It is possible that Nimbus could have called onUpdate before
+ // we started listening, so do a check on startup.
+ Services.tm.dispatchToMainThread(async () => {
+ await lazy.NimbusFeatures.searchConfiguration.ready();
+ this.#checkNimbusPrefs(true);
+ });
+
+ this.#maybeStartOpenSearchUpdateTimer();
+
+ return result;
+ }
+
+ /**
+ * 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.
+ *
+ * Dumps of remote settings should be available locally to avoid waiting
+ * for the network on startup. For desktop, the dumps are located in
+ * `services/settings/dumps/main/`.
+ */
+ async #setupRemoteSettings() {
+ // Now we have the values, listen for future updates.
+ let listener = this.#handleIgnoreListUpdated.bind(this);
+
+ const current = await lazy.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,
+ lazy.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) {
+ lazy.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(console.error);
+ }
+ }
+
+ /**
+ * 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.searchURLWithNoTerms.spec.toLowerCase();
+ if (
+ this.#submissionURLIgnoreList.some(code =>
+ url.includes(code.toLowerCase())
+ )
+ ) {
+ return true;
+ }
+ return 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);
+ }
+
+ /**
+ * 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.
+ */
+ #appDefaultEngine(privateMode = false) {
+ let defaultEngine = this.#getEngineByWebExtensionDetails(
+ privateMode && this.#searchPrivateDefault
+ ? this.#searchPrivateDefault
+ : this._searchDefault
+ );
+
+ if (Services.policies?.status == Ci.nsIEnterprisePolicies.ACTIVE) {
+ let activePolicies = Services.policies.getActivePolicies();
+ if (activePolicies.SearchEngines) {
+ if (activePolicies.SearchEngines.Default) {
+ return this.#getEngineByName(activePolicies.SearchEngines.Default);
+ }
+ if (activePolicies.SearchEngines.Remove?.includes(defaultEngine.name)) {
+ defaultEngine = null;
+ }
+ }
+ }
+
+ 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.#appDefaultEngine(false);
+ }
+
+ // Something unexpected has happened. In order to recover the app default
+ // engine, use the first visible engine that is also a general purpose engine.
+ // Worst case, we just use the first visible engine.
+ defaultEngine = this.#sortedVisibleEngines.find(
+ e => e.isGeneralPurposeEngine
+ );
+ return defaultEngine ? defaultEngine : this.#sortedVisibleEngines[0];
+ }
+
+ /**
+ * Loads engines asynchronously.
+ *
+ * @param {object} settings
+ * An object representing the search engine settings.
+ */
+ async #loadEngines(settings) {
+ // Get user's current settings and search engine before we load engines from
+ // config. These values will be compared after engines are loaded.
+ let prevMetaData = { ...settings?.metaData };
+ let prevCurrentEngineId = prevMetaData.defaultEngineId;
+ let prevAppDefaultEngineId = prevMetaData?.appDefaultEngineId;
+
+ lazy.logConsole.debug("#loadEngines: start");
+ let { engines, privateDefault } = await this._fetchEngineSelectorEngines();
+ this.#setDefaultAndOrdersFromSelector(engines, privateDefault);
+
+ // We've done what we can without the add-on manager, now ensure that
+ // it has finished starting before we continue.
+ await lazy.AddonManager.readyPromise;
+
+ let newEngines = await this.#loadEnginesFromConfig(engines);
+ for (let engine of newEngines) {
+ this.#addEngineToStore(engine);
+ }
+
+ lazy.logConsole.debug(
+ "#loadEngines: loading",
+ this.#startupExtensions.size,
+ "engines reported by AddonManager startup"
+ );
+ for (let extension of this.#startupExtensions) {
+ await this.#installExtensionEngine(
+ extension,
+ [lazy.SearchUtils.DEFAULT_TAG],
+ true
+ );
+ }
+ this.#startupExtensions.clear();
+
+ this.#loadEnginesFromPolicies();
+
+ this.#loadEnginesFromSettings(settings.engines);
+
+ // Settings file version 6 and below will need a migration to store the
+ // engine ids rather than engine names.
+ this._settings.migrateEngineIds(settings);
+
+ this.#loadEnginesMetadataFromSettings(settings.engines);
+
+ lazy.logConsole.debug("#loadEngines: done");
+
+ let newCurrentEngine = this._getEngineDefault(false);
+ let newCurrentEngineId = newCurrentEngine?.id;
+
+ this._settings.setMetaDataAttribute(
+ "appDefaultEngineId",
+ this.appDefaultEngine?.id
+ );
+
+ if (
+ this.#shouldDisplayRemovalOfEngineNotificationBox(
+ settings,
+ prevMetaData,
+ newCurrentEngineId,
+ prevCurrentEngineId,
+ prevAppDefaultEngineId
+ )
+ ) {
+ let newCurrentEngineName = newCurrentEngine?.name;
+
+ let [prevCurrentEngineName, prevAppDefaultEngineName] = [
+ settings.engines.find(e => e.id == prevCurrentEngineId)?._name,
+ settings.engines.find(e => e.id == prevAppDefaultEngineId)?._name,
+ ];
+
+ this._showRemovalOfSearchEngineNotificationBox(
+ prevCurrentEngineName || prevAppDefaultEngineName,
+ newCurrentEngineName
+ );
+ }
+ }
+
+ /**
+ * Helper function to determine if the removal of search engine notification
+ * box should be displayed.
+ *
+ * @param { object } settings
+ * The user's search engine settings.
+ * @param { object } prevMetaData
+ * The user's previous search settings metadata.
+ * @param { object } newCurrentEngineId
+ * The user's new current default engine.
+ * @param { object } prevCurrentEngineId
+ * The user's previous default engine.
+ * @param { object } prevAppDefaultEngineId
+ * The user's previous app default engine.
+ * @returns { boolean }
+ * Return true if the previous default engine has been removed and
+ * notification box should be displayed.
+ */
+ #shouldDisplayRemovalOfEngineNotificationBox(
+ settings,
+ prevMetaData,
+ newCurrentEngineId,
+ prevCurrentEngineId,
+ prevAppDefaultEngineId
+ ) {
+ if (
+ !Services.prefs.getBoolPref("browser.search.removeEngineInfobar.enabled")
+ ) {
+ return false;
+ }
+
+ // If for some reason we were unable to install any engines and hence no
+ // default engine, do not display the notification box
+ if (!newCurrentEngineId) {
+ return false;
+ }
+
+ // If the previous engine is still available, don't show the notification
+ // box.
+ if (prevCurrentEngineId && this._engines.has(prevCurrentEngineId)) {
+ return false;
+ }
+ if (!prevCurrentEngineId && this._engines.has(prevAppDefaultEngineId)) {
+ return false;
+ }
+
+ // Don't show the notification if the previous engine was an enterprise engine -
+ // the text doesn't quite make sense.
+ // let checkPolicyEngineId = prevCurrentEngineId ? prevCurrentEngineId : prevAppDefaultEngineId;
+ let checkPolicyEngineId = prevCurrentEngineId || prevAppDefaultEngineId;
+ if (checkPolicyEngineId) {
+ let engineSettings = settings.engines.find(
+ e => e.id == checkPolicyEngineId
+ );
+ if (engineSettings?._loadPath?.startsWith("[policy]")) {
+ return false;
+ }
+ }
+
+ // If the user's previous engine id is different than the new current
+ // engine id, or if the user was using the app default engine and the
+ // app default engine id is different than the new current engine id,
+ // we check if the user's settings metadata has been upddated.
+ if (
+ (prevCurrentEngineId && prevCurrentEngineId !== newCurrentEngineId) ||
+ (!prevCurrentEngineId &&
+ prevAppDefaultEngineId &&
+ prevAppDefaultEngineId !== newCurrentEngineId)
+ ) {
+ // Check settings metadata to detect an update to locale. Sometimes when
+ // the user changes their locale it causes a change in engines.
+ // If there is no update to settings metadata then the engine change was
+ // caused by an update to config rather than a user changing their locale.
+ if (!this.#didSettingsMetaDataUpdate(prevMetaData)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 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) {
+ lazy.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.
+ *
+ * This is prefixed with _ rather than # because it is
+ * called in test_reload_engines.js
+ *
+ * @param {integer} changeReason
+ * The reason reload engines is being called, one of
+ * Ci.nsISearchService.CHANGE_REASON*
+ */
+ async _maybeReloadEngines(changeReason) {
+ if (this.#maybeReloadDebounce) {
+ lazy.logConsole.debug("We're already waiting to reload engines.");
+ return;
+ }
+
+ if (!this.isInitialized || 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(changeReason).catch(console.error);
+ }, 10000);
+ lazy.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();
+
+ lazy.logConsole.debug("Running maybeReloadEngines");
+ this._reloadingEngines = true;
+
+ try {
+ await this._reloadEngines(settings, changeReason);
+ } catch (ex) {
+ lazy.logConsole.error("maybeReloadEngines failed", ex);
+ }
+ this._reloadingEngines = false;
+ lazy.logConsole.debug("maybeReloadEngines complete");
+ }
+
+ // This is prefixed with _ rather than # because it is called in
+ // test_remove_engine_notification_box.js
+ async _reloadEngines(settings, changeReason) {
+ // Capture the current engine state, in case we need to notify below.
+ let prevCurrentEngine = this.#currentEngine;
+ let prevPrivateEngine = this.#currentPrivateEngine;
+ let prevMetaData = { ...settings?.metaData };
+
+ // 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: appDefaultConfigEngines, privateDefault } =
+ await this._fetchEngineSelectorEngines();
+
+ let configEngines = [...appDefaultConfigEngines];
+ let oldEngineList = [...this._engines.values()];
+
+ for (let engine of oldEngineList) {
+ if (!engine.isAppProvided) {
+ if (engine instanceof lazy.AddonSearchEngine) {
+ // If this is an add-on search engine, check to see if it needs
+ // an update.
+ await engine.update();
+ }
+ continue;
+ }
+
+ let index = configEngines.findIndex(
+ e =>
+ e.webExtension.id == engine._extensionID &&
+ e.webExtension.locale == engine._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) {
+ engine.pendingRemoval = true;
+ 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
+ );
+ let locale =
+ replacementEngines[0].webExtension.locale ||
+ lazy.SearchUtils.DEFAULT_TAG;
+
+ // If the name is different, then we must treat the engine as different,
+ // and go through the remove and add cycle, rather than modifying the
+ // existing one.
+ let hasUpdated = await engine.updateIfNoNameChange({
+ configuration: configEngines[index],
+ locale,
+ });
+ if (!hasUpdated) {
+ // No matching name, so just remove it.
+ engine.pendingRemoval = true;
+ continue;
+ }
+ } else {
+ // This is an existing engine that we should update (we don't know if
+ // the configuration for this engine has changed or not).
+ await engine.update({
+ configuration: configEngines[index],
+ locale: engine._locale,
+ });
+ }
+
+ 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) {
+ lazy.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.
+
+ // Clear the current values, so that we'll completely reset.
+ this.#currentEngine = null;
+ this.#currentPrivateEngine = null;
+
+ // If the user's default is one of the private engines that is being removed,
+ // reset the stored setting, so that we correctly detect the change in
+ // in default.
+ if (prevCurrentEngine?.pendingRemoval) {
+ this._settings.setMetaDataAttribute("defaultEngineId", "");
+ }
+ if (prevPrivateEngine?.pendingRemoval) {
+ this._settings.setMetaDataAttribute("privateDefaultEngineId", "");
+ }
+
+ this.#setDefaultAndOrdersFromSelector(
+ appDefaultConfigEngines,
+ privateDefault
+ );
+
+ // If the defaultEngine has changed between the previous load and this one,
+ // dispatch the appropriate notifications.
+ if (prevCurrentEngine && this.defaultEngine !== prevCurrentEngine) {
+ this.#recordDefaultChangedEvent(
+ false,
+ prevCurrentEngine,
+ this.defaultEngine,
+ changeReason
+ );
+ lazy.SearchUtils.notifyAction(
+ this.#currentEngine,
+ lazy.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) {
+ lazy.SearchUtils.notifyAction(
+ this.#currentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+
+ if (
+ prevMetaData &&
+ settings.metaData &&
+ !this.#didSettingsMetaDataUpdate(prevMetaData) &&
+ prevCurrentEngine?.pendingRemoval &&
+ Services.prefs.getBoolPref("browser.search.removeEngineInfobar.enabled")
+ ) {
+ this._showRemovalOfSearchEngineNotificationBox(
+ prevCurrentEngine.name,
+ this.defaultEngine.name
+ );
+ }
+ }
+
+ if (
+ this.#separatePrivateDefault &&
+ prevPrivateEngine &&
+ this.defaultPrivateEngine !== prevPrivateEngine
+ ) {
+ this.#recordDefaultChangedEvent(
+ true,
+ prevPrivateEngine,
+ this.defaultPrivateEngine,
+ changeReason
+ );
+ lazy.SearchUtils.notifyAction(
+ this.#currentPrivateEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+
+ // Finally, remove any engines that need removing. We do this after sorting
+ // out the new default, as otherwise this could cause multiple notifications
+ // and the wrong engine to be selected as default.
+
+ for (let engine of this._engines.values()) {
+ if (!engine.pendingRemoval) {
+ continue;
+ }
+
+ // 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 lazy.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);
+ }
+ lazy.SearchUtils.notifyAction(
+ engine,
+ lazy.SearchUtils.MODIFIED_TYPE.REMOVED
+ );
+ }
+
+ // Save app default engine to the user's settings metaData incase it has
+ // been updated
+ this._settings.setMetaDataAttribute(
+ "appDefaultEngineId",
+ this.appDefaultEngine?.id
+ );
+
+ // If we are leaving an experiment, and the default is the same as the
+ // application default, we reset the user's setting to blank, so that
+ // future changes of the application default engine may take effect.
+ if (
+ prevMetaData.experiment &&
+ !this._settings.getMetaDataAttribute("experiment")
+ ) {
+ if (this.defaultEngine == this.appDefaultEngine) {
+ this._settings.setVerifiedMetaDataAttribute("defaultEngineId", "");
+ }
+ if (
+ this.#separatePrivateDefault &&
+ this.defaultPrivateEngine == this.appPrivateDefaultEngine
+ ) {
+ this._settings.setVerifiedMetaDataAttribute(
+ "privateDefaultEngineId",
+ ""
+ );
+ }
+ }
+
+ this.#dontSetUseSavedOrder = false;
+ // Clear out the sorted engines settings, so that we re-sort it if necessary.
+ this._cachedSortedEngines = null;
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "engines-reloaded"
+ );
+ }
+
+ #addEngineToStore(engine, skipDuplicateCheck = false) {
+ if (this.#engineMatchesIgnoreLists(engine)) {
+ lazy.logConsole.debug("#addEngineToStore: Ignoring engine");
+ return;
+ }
+
+ lazy.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.#getEngineByName(engine.name) &&
+ !hasSameNameAsUpdate
+ ) {
+ lazy.logConsole.debug(
+ "#addEngineToStore: Duplicate engine found, aborting!"
+ );
+ return;
+ }
+
+ if (engine._engineToUpdate) {
+ // Update the old engine by copying over the properties of the new engine
+ // that is loaded. It is necessary to copy over all the "private"
+ // properties (those without a getter or setter) from one object to the
+ // other. Other callers may hold a reference to the old engine, therefore,
+ // anywhere else that has a reference to the old engine will receive
+ // the properties that are updated because those other callers
+ // are referencing the same nsISearchEngine object in memory.
+ for (let p in engine) {
+ if (
+ !(
+ Object.getOwnPropertyDescriptor(engine, p)?.get ||
+ Object.getOwnPropertyDescriptor(engine, p)?.set
+ )
+ ) {
+ engine._engineToUpdate[p] = engine[p];
+ }
+ }
+
+ // The old engine is now updated
+ engine = engine._engineToUpdate;
+ engine._engineToUpdate = null;
+
+ // Update the engine Map with the updated engine
+ this._engines.set(engine.id, engine);
+
+ lazy.SearchUtils.notifyAction(
+ engine,
+ lazy.SearchUtils.MODIFIED_TYPE.CHANGED
+ );
+ } else {
+ // Not an update, just add the new engine.
+ this._engines.set(engine.id, engine);
+ // Only add the engine to the list of sorted engines if the initial list
+ // has already been built (i.e. if this._cachedSortedEngines 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._cachedSortedEngines && !this.#dontSetUseSavedOrder) {
+ this._cachedSortedEngines.push(engine);
+ this.#saveSortedEngineList();
+ }
+ lazy.SearchUtils.notifyAction(
+ engine,
+ lazy.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(engineSettings) {
+ if (!engineSettings) {
+ return;
+ }
+
+ for (let engineSetting of engineSettings) {
+ let eng = this.#getEngineByName(engineSetting._name);
+ if (eng) {
+ lazy.logConsole.debug(
+ "#loadEnginesMetadataFromSettings, transfering metadata for",
+ engineSetting._name,
+ engineSetting._metaData
+ );
+
+ // 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 === engineSetting?._metaData?.alias) {
+ delete engineSetting._metaData.alias;
+ }
+ eng._metaData = engineSetting._metaData || {};
+ }
+ }
+ }
+
+ #loadEnginesFromPolicies() {
+ if (Services.policies?.status != Ci.nsIEnterprisePolicies.ACTIVE) {
+ return;
+ }
+
+ let activePolicies = Services.policies.getActivePolicies();
+ if (!activePolicies.SearchEngines) {
+ return;
+ }
+ for (let engineDetails of activePolicies.SearchEngines.Add ?? []) {
+ let details = {
+ description: engineDetails.Description,
+ iconURL: engineDetails.IconURL ? engineDetails.IconURL.href : null,
+ name: engineDetails.Name,
+ // If the encoding is not specified or is falsy, we will fall back to
+ // the default encoding.
+ encoding: engineDetails.Encoding,
+ search_url: encodeURI(engineDetails.URLTemplate),
+ keyword: engineDetails.Alias,
+ search_url_post_params:
+ engineDetails.Method == "POST" ? engineDetails.PostData : undefined,
+ suggest_url: engineDetails.SuggestURLTemplate,
+ };
+ this.#addPolicyEngine(details);
+ }
+ }
+
+ #loadEnginesFromSettings(enginesCache) {
+ if (!enginesCache) {
+ return;
+ }
+
+ lazy.logConsole.debug(
+ "#loadEnginesFromSettings: Loading",
+ enginesCache.length,
+ "engines from settings"
+ );
+
+ let skippedEngines = 0;
+ for (let engineJSON of enginesCache) {
+ // We renamed isBuiltin to isAppProvided in bug 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") ||
+ // Old omni.ja engines also moved to in-app in Firefox 62.
+ loadPath.startsWith("jar:[app]/omni.ja"))
+ ) {
+ continue;
+ }
+
+ try {
+ let engine;
+ if (loadPath?.startsWith("[policy]")) {
+ skippedEngines++;
+ continue;
+ } else if (loadPath?.startsWith("[user]")) {
+ engine = new lazy.UserSearchEngine({ json: engineJSON });
+ } else if (engineJSON.extensionID ?? engineJSON._extensionID) {
+ engine = new lazy.AddonSearchEngine({
+ isAppProvided: false,
+ json: engineJSON,
+ });
+ } else {
+ engine = new lazy.OpenSearchEngine({
+ json: engineJSON,
+ });
+ }
+ this.#addEngineToStore(engine);
+ } catch (ex) {
+ lazy.logConsole.error(
+ "Failed to load",
+ engineJSON._name,
+ "from settings:",
+ ex,
+ engineJSON
+ );
+ }
+ }
+
+ if (skippedEngines) {
+ lazy.logConsole.debug(
+ "#loadEnginesFromSettings: skipped",
+ skippedEngines,
+ "built-in/policy engines."
+ );
+ }
+ }
+
+ // This is prefixed with _ rather than # because it is
+ // called in test_remove_engine_notification_box.js
+ async _fetchEngineSelectorEngines() {
+ let searchEngineSelectorProperties = {
+ locale: Services.locale.appLocaleAsBCP47,
+ region: lazy.Region.home || "default",
+ channel: lazy.SearchUtils.MODIFIED_APP_CHANNEL,
+ experiment:
+ lazy.NimbusFeatures.searchConfiguration.getVariable("experiment") ?? "",
+ distroID: lazy.SearchUtils.distroID ?? "",
+ };
+
+ for (let [key, value] of Object.entries(searchEngineSelectorProperties)) {
+ this._settings.setMetaDataAttribute(key, value);
+ }
+
+ let { engines, privateDefault } =
+ await this.#engineSelector.fetchEngineConfiguration(
+ searchEngineSelectorProperties
+ );
+
+ for (let e of engines) {
+ if (!e.webExtension) {
+ e.webExtension = {};
+ }
+ e.webExtension.locale =
+ e.webExtension?.locale ?? lazy.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() {
+ lazy.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.setMetaDataAttribute("useSavedOrder", true);
+
+ var engines = this.#sortedEngines;
+
+ for (var i = 0; i < engines.length; ++i) {
+ engines[i].setAttr("order", i + 1);
+ }
+ }
+
+ #buildSortedEngineList() {
+ // We must initialise _cachedSortedEngines 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._cachedSortedEngines = [];
+
+ // If the user has specified a custom engine order, read the order
+ // information from the metadata instead of the default prefs.
+ if (this._settings.getMetaDataAttribute("useSavedOrder")) {
+ lazy.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._cachedSortedEngines[orderNumber - 1]) {
+ this._cachedSortedEngines[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._cachedSortedEngines.filter(function (a) {
+ return !!a;
+ });
+ if (this._cachedSortedEngines.length != filteredEngines.length) {
+ needToSaveEngineList = true;
+ }
+ this._cachedSortedEngines = 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._cachedSortedEngines =
+ this._cachedSortedEngines.concat(alphaEngines));
+ }
+ lazy.logConsole.debug("#buildSortedEngineList: using default orders");
+
+ return (this._cachedSortedEngines = 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.
+ *
+ * This is a private method with _ rather than # because it is
+ * called in a test.
+ */
+ _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 app default engine should always be first in the list (except
+ // for distros, that we should respect).
+ const appDefault = this.appDefaultEngine;
+ maybeAddEngineToSort(appDefault);
+
+ // If there's a private default, and it is different to the normal
+ // default, then it should be second in the list.
+ const appPrivateDefault = this.appPrivateDefaultEngine;
+ if (appPrivateDefault && appPrivateDefault != appDefault) {
+ maybeAddEngineToSort(appPrivateDefault);
+ }
+
+ 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 the visible engines.
+ *
+ * @returns {Array<SearchEngine>}
+ */
+
+ get #sortedVisibleEngines() {
+ return this.#sortedEngines.filter(engine => !engine.hidden);
+ }
+
+ /**
+ * Migrates legacy add-ons which used the OpenSearch definitions to
+ * WebExtensions, if an equivalent WebExtension is installed.
+ *
+ * Run during the background checks.
+ */
+ async #migrateLegacyEngines() {
+ lazy.logConsole.debug("Running migrate legacy engines");
+
+ const matchRegExp = /extensions\/(.*?)\.xpi!/i;
+ for (let engine of this._engines.values()) {
+ if (
+ !engine.isAppProvided &&
+ !engine._extensionID &&
+ engine._loadPath.includes("[profile]/extensions/")
+ ) {
+ let match = engine._loadPath.match(matchRegExp);
+ if (match?.[1]) {
+ // There's a chance here that the WebExtension might not be
+ // installed any longer, even though the engine is. We'll deal
+ // with that in `checkWebExtensionEngines`.
+ let engines = await this.getEnginesByExtensionID(match[1]);
+ if (engines.length) {
+ lazy.logConsole.debug(
+ `Migrating ${engine.name} to WebExtension install`
+ );
+
+ if (this.defaultEngine == engine) {
+ this.defaultEngine = engines[0];
+ }
+ await this.removeEngine(engine);
+ }
+ }
+ }
+ }
+
+ lazy.logConsole.debug("Migrate legacy engines complete");
+ }
+
+ /**
+ * Checks if Search Engines associated with WebExtensions are valid and
+ * up-to-date, and reports them via telemetry if not.
+ *
+ * Run during the background checks.
+ */
+ async #checkWebExtensionEngines() {
+ lazy.logConsole.debug("Running check on WebExtension engines");
+
+ for (let engine of this._engines.values()) {
+ if (engine instanceof lazy.AddonSearchEngine && !engine.isAppProvided) {
+ await engine.checkAndReportIfSettingsValid();
+ }
+ }
+ lazy.logConsole.debug("WebExtension engine check complete");
+ }
+
+ /**
+ * Counts the number of secure, insecure, securely updated and insecurely
+ * updated OpenSearch engines the user has installed and reports those
+ * counts via telemetry.
+ *
+ * Run during the background checks.
+ */
+ async #addOpenSearchTelemetry() {
+ let totalSecure = 0;
+ let totalInsecure = 0;
+ let totalWithSecureUpdates = 0;
+ let totalWithInsecureUpdates = 0;
+
+ let engine;
+ let searchURI;
+ let updateURI;
+ for (let elem of this._engines) {
+ engine = elem[1];
+ if (engine instanceof lazy.OpenSearchEngine) {
+ searchURI = engine.searchURLWithNoTerms;
+ updateURI = engine._updateURI;
+
+ if (lazy.SearchUtils.isSecureURIForOpenSearch(searchURI)) {
+ totalSecure++;
+ } else {
+ totalInsecure++;
+ }
+
+ if (updateURI && lazy.SearchUtils.isSecureURIForOpenSearch(updateURI)) {
+ totalWithSecureUpdates++;
+ } else if (updateURI) {
+ totalWithInsecureUpdates++;
+ }
+ }
+ }
+
+ Services.telemetry.scalarSet(
+ "browser.searchinit.secure_opensearch_engine_count",
+ totalSecure
+ );
+ Services.telemetry.scalarSet(
+ "browser.searchinit.insecure_opensearch_engine_count",
+ totalInsecure
+ );
+ Services.telemetry.scalarSet(
+ "browser.searchinit.secure_opensearch_update_count",
+ totalWithSecureUpdates
+ );
+ Services.telemetry.scalarSet(
+ "browser.searchinit.insecure_opensearch_update_count",
+ totalWithInsecureUpdates
+ );
+ }
+
+ /**
+ * Creates and adds a WebExtension based engine.
+ *
+ * @param {object} options
+ * Options for the engine.
+ * @param {Extension} options.extension
+ * An Extension object containing data about the extension.
+ * @param {string} [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({
+ extension,
+ locale = lazy.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) {
+ lazy.logConsole.debug(
+ "Engine already loaded via settings, skipping due to APP_STARTUP:",
+ extension.id
+ );
+ return engine;
+ }
+ }
+
+ // 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.isInitialized && !extension.isAppProvided && !initEngine) {
+ await this.init();
+ }
+
+ let isCurrent = false;
+
+ for (let engine of this._engines.values()) {
+ if (
+ !engine.extensionID &&
+ engine._loadPath.startsWith(`jar:[profile]/extensions/${extension.id}`)
+ ) {
+ // This is a legacy extension engine that needs to be migrated to WebExtensions.
+ lazy.logConsole.debug("Migrating existing engine");
+ isCurrent = isCurrent || this.defaultEngine == engine;
+ await this.removeEngine(engine);
+ }
+ }
+
+ let newEngine = new lazy.AddonSearchEngine({
+ isAppProvided: extension.isAppProvided,
+ details: {
+ extensionID: extension.id,
+ locale,
+ },
+ });
+ await newEngine.init({
+ extension,
+ locale,
+ });
+
+ let existingEngine = this.#getEngineByName(newEngine.name);
+ if (existingEngine) {
+ throw Components.Exception(
+ `An engine called ${newEngine.name} already exists!`,
+ Cr.NS_ERROR_FILE_ALREADY_EXISTS
+ );
+ }
+
+ this.#addEngineToStore(newEngine);
+ if (isCurrent) {
+ this.defaultEngine = newEngine;
+ }
+ return newEngine;
+ }
+
+ /**
+ * 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 isDefault = engine == this.defaultEngine;
+ let isDefaultPrivate = engine == this.defaultPrivateEngine;
+
+ let originalName = engine.name;
+ let locale = engine._locale || lazy.SearchUtils.DEFAULT_TAG;
+ let configuration =
+ engines.find(
+ e =>
+ e.webExtension.id == extension.id && e.webExtension.locale == locale
+ ) ?? {};
+
+ await engine.update({
+ configuration,
+ extension,
+ locale,
+ });
+
+ if (engine.name != originalName) {
+ if (isDefault) {
+ this._settings.setVerifiedMetaDataAttribute(
+ "defaultEngineId",
+ engine.id
+ );
+ }
+ if (isDefaultPrivate) {
+ this._settings.setVerifiedMetaDataAttribute(
+ "privateDefaultEngineId",
+ engine.id
+ );
+ }
+ this._cachedSortedEngines = null;
+ }
+ }
+ return extensionEngines;
+ }
+
+ async #installExtensionEngine(extension, locales, initEngine = false) {
+ lazy.logConsole.debug("installExtensionEngine:", extension.id);
+
+ let installLocale = async locale => {
+ return this._createAndAddEngine({ extension, locale, initEngine });
+ };
+
+ let engines = [];
+ for (let locale of locales) {
+ lazy.logConsole.debug(
+ "addEnginesFromExtension: installing:",
+ extension.id,
+ ":",
+ locale
+ );
+ engines.push(await installLocale(locale));
+ }
+ return engines;
+ }
+
+ #internalRemoveEngine(engine) {
+ // Remove the engine from _sortedEngines
+ if (this._cachedSortedEngines) {
+ var index = this._cachedSortedEngines.indexOf(engine);
+ if (index == -1) {
+ throw Components.Exception(
+ "Can't find engine to remove in _sortedEngines!",
+ Cr.NS_ERROR_FAILURE
+ );
+ }
+ this._cachedSortedEngines.splice(index, 1);
+ }
+
+ // Remove the engine from the internal store
+ this._engines.delete(engine.id);
+ }
+
+ /**
+ * Helper function to find a new default engine and set it. This could
+ * be used if there is not default set yet, or if the current default is
+ * being removed.
+ *
+ * This function will not consider engines that have a `pendingRemoval`
+ * property set to true.
+ *
+ * The new default will be chosen from (in order):
+ *
+ * - Existing default from configuration, if it is not hidden.
+ * - The first non-hidden engine that is a general search engine.
+ * - If all other engines are hidden, unhide the default from the configuration.
+ * - If the default from the configuration is the one being removed, unhide
+ * the first general search engine, or first visible 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.
+ */
+ #findAndSetNewDefaultEngine({ privateMode }) {
+ // First to the app default engine...
+ let newDefault = privateMode
+ ? this.appPrivateDefaultEngine
+ : this.appDefaultEngine;
+
+ if (!newDefault || newDefault.hidden || newDefault.pendingRemoval) {
+ let sortedEngines = this.#sortedVisibleEngines;
+ let generalSearchEngines = sortedEngines.filter(
+ e => e.isGeneralPurposeEngine
+ );
+
+ // then to the first visible general search engine that isn't excluded...
+ let firstVisible = generalSearchEngines.find(e => !e.pendingRemoval);
+ if (firstVisible) {
+ newDefault = firstVisible;
+ } else if (newDefault) {
+ // then to the app default if it is not the one that is excluded...
+ if (!newDefault.pendingRemoval) {
+ newDefault.hidden = false;
+ } else {
+ newDefault = null;
+ }
+ }
+
+ // and finally as a last resort we unhide the first engine
+ // even if the name is the same as the excluded one (should never happen).
+ if (!newDefault) {
+ if (!firstVisible) {
+ sortedEngines = this.#sortedEngines;
+ firstVisible = sortedEngines.find(e => e.isGeneralPurposeEngine);
+ if (!firstVisible) {
+ firstVisible = sortedEngines[0];
+ }
+ }
+ if (firstVisible) {
+ firstVisible.hidden = false;
+ newDefault = firstVisible;
+ }
+ }
+ }
+ // We tried out best but something went very wrong.
+ if (!newDefault) {
+ lazy.logConsole.error("Could not find a replacement default engine.");
+ 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.
+ this.#setEngineDefault(privateMode, newDefault);
+
+ return privateMode ? this.#currentPrivateEngine : 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
+ * @param {SearchUtils.REASON_CHANGE_MAP} changeSource
+ * The source of the change of engine.
+ */
+ #setEngineDefault(privateMode, newEngine, changeSource) {
+ // 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 lazy.SearchEngine)
+ ) {
+ throw Components.Exception(
+ "Invalid argument passed to defaultEngine setter",
+ Cr.NS_ERROR_INVALID_ARG
+ );
+ }
+
+ const newCurrentEngine = this._engines.get(newEngine.id);
+ 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 = lazy.SearchUtils.getVerificationHash(
+ newCurrentEngine._loadPath
+ );
+ let currentHash = newCurrentEngine.getAttr("loadPathHash");
+ if (!currentHash || currentHash != loadPathHash) {
+ newCurrentEngine.setAttr("loadPathHash", loadPathHash);
+ lazy.SearchUtils.notifyAction(
+ newCurrentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.CHANGED
+ );
+ }
+ }
+
+ let currentEngine = privateMode
+ ? this.#currentPrivateEngine
+ : this.#currentEngine;
+
+ if (newCurrentEngine == currentEngine) {
+ return;
+ }
+
+ // Ensure that we reset an engine override if it was previously overridden.
+ currentEngine?.removeExtensionOverride();
+
+ if (privateMode) {
+ this.#currentPrivateEngine = newCurrentEngine;
+ } else {
+ 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
+ // "app default" engine. So clear the user pref when the currentEngine is
+ // set to the build's app default engine, so that the currentEngine getter
+ // falls back to whatever the default is.
+ // However, we do not do this whilst we are running an experiment - an
+ // experiment must preseve the user's choice of default engine during it's
+ // runtime and when it ends. Once the experiment ends, we will reset the
+ // attribute elsewhere.
+ let newId = newCurrentEngine.id;
+ const appDefaultEngine = privateMode
+ ? this.appPrivateDefaultEngine
+ : this.appDefaultEngine;
+ if (
+ newCurrentEngine == appDefaultEngine &&
+ !lazy.NimbusFeatures.searchConfiguration.getVariable("experiment")
+ ) {
+ newId = "";
+ }
+
+ this._settings.setVerifiedMetaDataAttribute(
+ privateMode ? "privateDefaultEngineId" : "defaultEngineId",
+ newId
+ );
+
+ // Only do this if we're initialized though - this function can get called
+ // during initalization.
+ if (this.isInitialized) {
+ this.#recordDefaultChangedEvent(
+ privateMode,
+ currentEngine,
+ newCurrentEngine,
+ changeSource
+ );
+ this.#recordTelemetryData();
+ }
+
+ lazy.SearchUtils.notifyAction(
+ newCurrentEngine,
+ lazy.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) {
+ lazy.SearchUtils.notifyAction(
+ newCurrentEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+ }
+
+ #onSeparateDefaultPrefChanged(prefName, previousValue, currentValue) {
+ // Clear out the sorted engines settings, so that we re-sort it if necessary.
+ this._cachedSortedEngines = 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)) {
+ lazy.SearchUtils.notifyAction(
+ // Always notify with the new private engine, the function checks
+ // the preference value for us.
+ this.defaultPrivateEngine,
+ lazy.SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ );
+ }
+ // Always notify about the change of status of private default if the user
+ // toggled the UI.
+ if (
+ prefName ==
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault"
+ ) {
+ if (!previousValue && currentValue) {
+ this.#recordDefaultChangedEvent(
+ true,
+ null,
+ this._getEngineDefault(true),
+ Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_SPLIT
+ );
+ } else {
+ this.#recordDefaultChangedEvent(
+ true,
+ this._getEngineDefault(true),
+ null,
+ Ci.nsISearchService.CHANGE_REASON_USER_PRIVATE_SPLIT
+ );
+ }
+ }
+ // Update the telemetry data.
+ this.#recordTelemetryData();
+ }
+
+ #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.
+ console.error("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 = lazy.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.searchUrlDomain;
+ for (let innerEngine of this._engines.values()) {
+ if (!innerEngine.isAppProvided) {
+ continue;
+ }
+
+ if (innerEngine.searchUrlDomain == 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.searchURLWithNoTerms;
+ uri = uri
+ .mutate()
+ .setUserPass("") // Avoid reporting a username or password.
+ .finalize();
+ engineData.submissionURL = uri.spec;
+ }
+
+ return [engine.telemetryId, engineData];
+ }
+
+ /**
+ * Records an event for where the default engine is changed. This is
+ * recorded to both Glean and Telemetry.
+ *
+ * The Glean GIFFT functionality is not used here because we use longer
+ * names in the extra arguments to the event.
+ *
+ * @param {boolean} isPrivate
+ * True if this is a event about a private engine.
+ * @param {SearchEngine} [previousEngine]
+ * The previously default search engine.
+ * @param {SearchEngine} [newEngine]
+ * The new default search engine.
+ * @param {string} changeSource
+ * The source of the change of default.
+ */
+ #recordDefaultChangedEvent(
+ isPrivate,
+ previousEngine,
+ newEngine,
+ changeSource = Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ ) {
+ changeSource = REASON_CHANGE_MAP.get(changeSource) ?? "unknown";
+ Services.telemetry.setEventRecordingEnabled("search", true);
+ let telemetryId;
+ let engineInfo;
+ // If we are toggling the separate private browsing settings, we might not
+ // have an engine to record.
+ if (newEngine) {
+ [telemetryId, engineInfo] = this.#getEngineInfo(newEngine);
+ } else {
+ telemetryId = "";
+ engineInfo = {
+ name: "",
+ loadPath: "",
+ submissionURL: "",
+ };
+ }
+
+ let submissionURL = engineInfo.submissionURL ?? "";
+ Services.telemetry.recordEvent(
+ "search",
+ "engine",
+ isPrivate ? "change_private" : "change_default",
+ changeSource,
+ {
+ // In docshell tests, the previous engine does not exist, so we allow
+ // for the previousEngine to be undefined.
+ prev_id: previousEngine?.telemetryId ?? "",
+ new_id: telemetryId,
+ new_name: engineInfo.name,
+ new_load_path: engineInfo.loadPath,
+ // Telemetry has a limit of 80 characters.
+ new_sub_url: submissionURL.slice(0, 80),
+ }
+ );
+
+ let extraArgs = {
+ // In docshell tests, the previous engine does not exist, so we allow
+ // for the previousEngine to be undefined.
+ previous_engine_id: previousEngine?.telemetryId ?? "",
+ new_engine_id: telemetryId,
+ new_display_name: engineInfo.name,
+ new_load_path: engineInfo.loadPath,
+ // Glean has a limit of 100 characters.
+ new_submission_url: submissionURL.slice(0, 100),
+ change_source: changeSource,
+ };
+ if (isPrivate) {
+ Glean.searchEnginePrivate.changed.record(extraArgs);
+ } else {
+ Glean.searchEngineDefault.changed.record(extraArgs);
+ }
+ }
+
+ /**
+ * Records the user's current default engine (normal and private) data to
+ * telemetry.
+ */
+ #recordTelemetryData() {
+ let info = this.getDefaultEngineInfo();
+
+ Glean.searchEngineDefault.engineId.set(info.defaultSearchEngine);
+ Glean.searchEngineDefault.displayName.set(
+ info.defaultSearchEngineData.name
+ );
+ Glean.searchEngineDefault.loadPath.set(
+ info.defaultSearchEngineData.loadPath
+ );
+ Glean.searchEngineDefault.submissionUrl.set(
+ info.defaultSearchEngineData.submissionURL ?? "blank:"
+ );
+ Glean.searchEngineDefault.verified.set(info.defaultSearchEngineData.origin);
+
+ Glean.searchEnginePrivate.engineId.set(
+ info.defaultPrivateSearchEngine ?? ""
+ );
+
+ if (info.defaultPrivateSearchEngineData) {
+ Glean.searchEnginePrivate.displayName.set(
+ info.defaultPrivateSearchEngineData.name
+ );
+ Glean.searchEnginePrivate.loadPath.set(
+ info.defaultPrivateSearchEngineData.loadPath
+ );
+ Glean.searchEnginePrivate.submissionUrl.set(
+ info.defaultPrivateSearchEngineData.submissionURL ?? "blank:"
+ );
+ Glean.searchEnginePrivate.verified.set(
+ info.defaultPrivateSearchEngineData.origin
+ );
+ } else {
+ Glean.searchEnginePrivate.displayName.set("");
+ Glean.searchEnginePrivate.loadPath.set("");
+ Glean.searchEnginePrivate.submissionUrl.set("blank:");
+ Glean.searchEnginePrivate.verified.set("");
+ }
+ }
+
+ #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);
+ lazy.SearchStaticData.getAlternateDomains(
+ urlParsingInfo.mainDomain
+ ).forEach(d => processDomain(d, true));
+ }
+ }
+
+ #nimbusSearchUpdatedFun = null;
+
+ async #nimbusSearchUpdated() {
+ this.#checkNimbusPrefs();
+ Services.search.wrappedJSObject._maybeReloadEngines(
+ Ci.nsISearchService.CHANGE_REASON_EXPERIMENT
+ );
+ }
+
+ /**
+ * Check the prefs are correctly updated for users enrolled in a Nimbus experiment.
+ *
+ * @param {boolean} isStartup
+ * Whether this function was called as part of the startup flow.
+ */
+ #checkNimbusPrefs(isStartup = false) {
+ // If we are in an experiment we may need to check the status on startup, otherwise
+ // ignore the call to check on startup so we do not reset users prefs when they are
+ // not an experiment.
+ if (
+ isStartup &&
+ !lazy.NimbusFeatures.searchConfiguration.getVariable("experiment")
+ ) {
+ return;
+ }
+ let nimbusPrivateDefaultUIEnabled =
+ lazy.NimbusFeatures.searchConfiguration.getVariable(
+ "seperatePrivateDefaultUIEnabled"
+ );
+ let nimbusPrivateDefaultUrlbarResultEnabled =
+ lazy.NimbusFeatures.searchConfiguration.getVariable(
+ "seperatePrivateDefaultUrlbarResultEnabled"
+ );
+
+ let previousPrivateDefault = this.defaultPrivateEngine;
+ let uiWasEnabled = this._separatePrivateDefaultEnabledPrefValue;
+ if (
+ this._separatePrivateDefaultEnabledPrefValue !=
+ nimbusPrivateDefaultUIEnabled
+ ) {
+ Services.prefs.setBoolPref(
+ `${lazy.SearchUtils.BROWSER_SEARCH_PREF}separatePrivateDefault.ui.enabled`,
+ nimbusPrivateDefaultUIEnabled
+ );
+ let newPrivateDefault = this.defaultPrivateEngine;
+ if (previousPrivateDefault != newPrivateDefault) {
+ if (!uiWasEnabled) {
+ this.#recordDefaultChangedEvent(
+ true,
+ null,
+ newPrivateDefault,
+ Ci.nsISearchService.CHANGE_REASON_EXPERIMENT
+ );
+ } else {
+ this.#recordDefaultChangedEvent(
+ true,
+ previousPrivateDefault,
+ null,
+ Ci.nsISearchService.CHANGE_REASON_EXPERIMENT
+ );
+ }
+ }
+ }
+ if (
+ this.separatePrivateDefaultUrlbarResultEnabled !=
+ nimbusPrivateDefaultUrlbarResultEnabled
+ ) {
+ Services.prefs.setBoolPref(
+ `${lazy.SearchUtils.BROWSER_SEARCH_PREF}separatePrivateDefault.urlbarResult.enabled`,
+ nimbusPrivateDefaultUrlbarResultEnabled
+ );
+ }
+ }
+
+ #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;
+
+ this.#nimbusSearchUpdatedFun = this.#nimbusSearchUpdated.bind(this);
+ lazy.NimbusFeatures.searchConfiguration.onUpdate(
+ this.#nimbusSearchUpdatedFun
+ );
+
+ Services.obs.addObserver(this, lazy.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,
+ },
+ };
+ IOUtils.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.isInitialized) {
+ lazy.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
+ );
+ }
+
+ // This is prefixed with _ rather than # because it is
+ // called in a test.
+ _removeObservers() {
+ if (this.ignoreListListener) {
+ lazy.IgnoreLists.unsubscribe(this.ignoreListListener);
+ delete this.ignoreListListener;
+ }
+ if (this.#queuedIdle) {
+ this.idleService.removeIdleObserver(this, RECONFIG_IDLE_TIME_SEC);
+ this.#queuedIdle = false;
+ }
+
+ this._settings.removeObservers();
+
+ lazy.NimbusFeatures.searchConfiguration.offUpdate(
+ this.#nimbusSearchUpdatedFun
+ );
+
+ Services.obs.removeObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
+ Services.obs.removeObserver(this, QUIT_APPLICATION_TOPIC);
+ Services.obs.removeObserver(this, TOPIC_LOCALES_CHANGE);
+ Services.obs.removeObserver(this, lazy.Region.REGION_TOPIC);
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsISearchService",
+ "nsIObserver",
+ "nsITimerCallback",
+ ]);
+
+ // nsIObserver
+ observe(engine, topic, verb) {
+ switch (topic) {
+ case lazy.SearchUtils.TOPIC_ENGINE_MODIFIED:
+ switch (verb) {
+ case lazy.SearchUtils.MODIFIED_TYPE.LOADED:
+ engine = engine.QueryInterface(Ci.nsISearchEngine);
+ lazy.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 lazy.SearchUtils.MODIFIED_TYPE.ADDED:
+ case lazy.SearchUtils.MODIFIED_TYPE.CHANGED:
+ case lazy.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;
+ lazy.logConsole.debug(
+ "Reloading engines after idle due to configuration change"
+ );
+ this._maybeReloadEngines(
+ Ci.nsISearchService.CHANGE_REASON_CONFIG
+ ).catch(console.error);
+ 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(
+ Ci.nsISearchService.CHANGE_REASON_LOCALE
+ ).catch(console.error);
+ }
+ });
+ break;
+ case lazy.Region.REGION_TOPIC:
+ lazy.logConsole.debug("Region updated:", lazy.Region.home);
+ this._maybeReloadEngines(
+ Ci.nsISearchService.CHANGE_REASON_REGION
+ ).catch(console.error);
+ break;
+ }
+ }
+
+ /**
+ * Create an engine object from the search configuration details.
+ *
+ * This method is prefixed with _ rather than # because it is
+ * called in a test.
+ *
+ * @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) {
+ lazy.logConsole.debug("_makeEngineFromConfig:", config);
+ let locale =
+ "locale" in config.webExtension
+ ? config.webExtension.locale
+ : lazy.SearchUtils.DEFAULT_TAG;
+
+ let engine = new lazy.AddonSearchEngine({
+ isAppProvided: true,
+ details: {
+ extensionID: config.webExtension.id,
+ locale,
+ },
+ });
+ await engine.init({
+ locale,
+ config,
+ });
+ return engine;
+ }
+
+ /**
+ * @param {object} metaData
+ * The metadata object that defines the details of the engine.
+ * @returns {boolean}
+ * Returns true if metaData has different property values than
+ * the cached _metaData.
+ */
+ #didSettingsMetaDataUpdate(metaData) {
+ let metaDataProperties = [
+ "locale",
+ "region",
+ "channel",
+ "experiment",
+ "distroID",
+ ];
+
+ return metaDataProperties.some(p => {
+ return metaData?.[p] !== this._settings.getMetaDataAttribute(p);
+ });
+ }
+
+ /**
+ * Shows an infobar to notify the user their default search engine has been
+ * removed and replaced by a new default search engine.
+ *
+ * This method is prefixed with _ rather than # because it is
+ * called in a test.
+ *
+ * @param {string} prevCurrentEngineName
+ * The name of the previous default engine that will be replaced.
+ * @param {string} newCurrentEngineName
+ * The name of the engine that will be the new default engine.
+ *
+ */
+ _showRemovalOfSearchEngineNotificationBox(
+ prevCurrentEngineName,
+ newCurrentEngineName
+ ) {
+ let win = Services.wm.getMostRecentBrowserWindow();
+ win.BrowserSearch.removalOfSearchEngineNotificationBox(
+ prevCurrentEngineName,
+ newCurrentEngineName
+ );
+ }
+
+ /**
+ * Maybe starts the timer for OpenSearch engine updates. This will be set
+ * only if updates are enabled and there are OpenSearch engines installed
+ * which have updates.
+ */
+ #maybeStartOpenSearchUpdateTimer() {
+ if (
+ this.#openSearchUpdateTimerStarted ||
+ !Services.prefs.getBoolPref(
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "update",
+ true
+ )
+ ) {
+ return;
+ }
+
+ let engineWithUpdates = [...this._engines.values()].find(
+ engine => engine instanceof lazy.OpenSearchEngine && engine._hasUpdates
+ );
+
+ if (engineWithUpdates) {
+ lazy.logConsole.debug("Engine with updates found, setting update timer");
+ lazy.timerManager.registerTimer(
+ OPENSEARCH_UPDATE_TIMER_TOPIC,
+ this,
+ OPENSEARCH_UPDATE_TIMER_INTERVAL,
+ true
+ );
+ this.#openSearchUpdateTimerStarted = true;
+ }
+ }
+} // end SearchService class
+
+var engineUpdateService = {
+ scheduleNextUpdate(engine) {
+ var interval = engine._updateInterval || OPENSEARCH_DEFAULT_UPDATE_INTERVAL;
+ var milliseconds = interval * 86400000; // |interval| is in days
+ engine.setAttr("updateexpir", Date.now() + milliseconds);
+ },
+
+ update(engine) {
+ engine = engine.wrappedJSObject;
+ lazy.logConsole.debug("update called for", engine._name);
+ if (
+ !Services.prefs.getBoolPref(
+ lazy.SearchUtils.BROWSER_SEARCH_PREF + "update",
+ true
+ ) ||
+ !engine._hasUpdates
+ ) {
+ return;
+ }
+
+ let testEngine = null;
+ let updateURI = engine._updateURI;
+ if (updateURI) {
+ lazy.logConsole.debug("updating", engine.name, updateURI.spec);
+ testEngine = new lazy.OpenSearchEngine();
+ testEngine._engineToUpdate = engine;
+ try {
+ testEngine.install(updateURI);
+ } catch (ex) {
+ lazy.logConsole.error("Failed to update", engine.name, ex);
+ }
+ } else {
+ lazy.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 = lazy.RemoteSettings(
+ lazy.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.
+ console.error(ex);
+ }
+ lazy.logConsole.debug("Allow list is:", result);
+ return result;
+ }
+}
diff --git a/toolkit/components/search/SearchSettings.sys.mjs b/toolkit/components/search/SearchSettings.sys.mjs
new file mode 100644
index 0000000000..175b0a3d2e
--- /dev/null
+++ b/toolkit/components/search/SearchSettings.sys.mjs
@@ -0,0 +1,603 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "logConsole", () => {
+ return console.createInstance({
+ prefix: "SearchSettings",
+ maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn",
+ });
+});
+
+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.
+ */
+export 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;
+
+ /**
+ * A reference to the search service so that we can save the engines list.
+ */
+ #searchService = null;
+
+ /*
+ * The user's settings file read from disk so we can persist metadata for
+ * engines that are default or hidden, the user's locale and region, hashes
+ * for the loadPath, and hashes for default and private default engines.
+ * This is the JSON we read from disk and save to disk when there's an update
+ * to the settings.
+ *
+ * Structure of settings:
+ * Object { version: <number>,
+ * engines: [...],
+ * metaData: {...},
+ * }
+ *
+ * Settings metaData is the active metadata for setting and getting attributes.
+ * When a new metadata attribute is set, we save it to #settings.metaData and
+ * write #settings to disk.
+ *
+ * #settings.metaData attributes:
+ * @property {string} current
+ * The current user-set default engine. The associated hash is called
+ * 'hash'.
+ * @property {string} private
+ * The current user-set private engine. The associated hash is called
+ * 'privateHash'.
+ * The current and prviate objects have associated hash fields to validate
+ * the value is set by the application.
+ * @property {string} appDefaultEngine
+ * @property {string} channel
+ * Configuration is restricted to the specified channel. ESR is an example
+ * of a channel.
+ * @property {string} distroID
+ * Specifies which distribution the default engine is included in.
+ * @property {string} experiment
+ * Specifies if the application is running on an experiment.
+ * @property {string} locale
+ * @property {string} region
+ * @property {boolean} useSavedOrder
+ * True if the user's order information stored in settings is used.
+ *
+ */
+ #settings = null;
+
+ /**
+ * @type {object} A deep copy of #settings.
+ * #cachedSettings is updated when we read the settings from disk and when
+ * we write settings to disk. #cachedSettings is compared with #settings
+ * before we do a write to disk. If there's no change to the settings
+ * attributes, then we don't write the settings to disk.
+ */
+ #cachedSettings = {};
+
+ addObservers() {
+ Services.obs.addObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
+ Services.obs.addObserver(this, lazy.SearchUtils.TOPIC_SEARCH_SERVICE);
+ }
+
+ /**
+ * Cleans up, removing observers.
+ */
+ removeObservers() {
+ Services.obs.removeObserver(this, lazy.SearchUtils.TOPIC_ENGINE_MODIFIED);
+ Services.obs.removeObserver(this, lazy.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 = PathUtils.join(
+ PathUtils.profileDir,
+ SETTINGS_FILENAME
+ );
+ json = await IOUtils.readJSON(settingsFilePath, { decompress: true });
+ if (!json.engines || !json.engines.length) {
+ throw new Error("no engine in the file");
+ }
+ } catch (ex) {
+ lazy.logConsole.debug("get: No settings file exists, new profile?", ex);
+ json = {};
+ }
+
+ this.#settings = json;
+ this.#cachedSettings = structuredClone(json);
+
+ if (!this.#settings.metaData) {
+ this.#settings.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 (
+ this.#settings.version < 6 ||
+ !("useSavedOrder" in this.#settings.metaData)
+ ) {
+ const prefName = lazy.SearchUtils.BROWSER_SEARCH_PREF + "useDBForOrder";
+ let useSavedOrder = Services.prefs.getBoolPref(prefName, false);
+
+ this.setMetaDataAttribute("useSavedOrder", useSavedOrder);
+
+ // Clear the old pref so it isn't lying around.
+ Services.prefs.clearUserPref(prefName);
+ }
+
+ // Added in Firefox 110.
+ if (this.#settings.version < 8 && Array.isArray(this.#settings.engines)) {
+ this.#migrateTelemetryLoadPaths();
+ }
+
+ return structuredClone(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;
+ }
+ lazy.logConsole.debug("batchTask: Invalidating engine settings");
+ await this._write();
+ };
+ this._batchTask = new lazy.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;
+ }
+ lazy.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 = lazy.SearchUtils.SETTINGS_VERSION;
+ settings.engines = [...this.#searchService._engines.values()].map(engine =>
+ JSON.parse(JSON.stringify(engine))
+ );
+ settings.metaData = this.#settings.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.#settings?.engines) {
+ for (let engine of this.#settings.engines) {
+ let included = settings.engines.some(e => e._name == engine._name);
+ if (engine._isAppProvided && !included) {
+ settings.engines.push(engine);
+ }
+ }
+ }
+
+ // Update the local copy.
+ this.#settings = settings;
+
+ try {
+ if (!settings.engines.length) {
+ throw new Error("cannot write without any engine.");
+ }
+
+ if (this.isCurrentAndCachedSettingsEqual()) {
+ lazy.logConsole.debug(
+ "_write: Settings unchanged. Did not write to disk."
+ );
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "write-prevented-when-settings-unchanged"
+ );
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "write-settings-to-disk-complete"
+ );
+
+ return;
+ }
+
+ // At this point, the settings and cached settings are different. We
+ // write settings to disk and update #cachedSettings.
+ this.#cachedSettings = structuredClone(this.#settings);
+
+ lazy.logConsole.debug("_write: Writing to settings file.");
+ let path = PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME);
+ await IOUtils.writeJSON(path, settings, {
+ compress: true,
+ tmpPath: path + ".tmp",
+ });
+ lazy.logConsole.debug("_write: settings file written to disk.");
+ Services.obs.notifyObservers(
+ null,
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ "write-settings-to-disk-complete"
+ );
+ } catch (ex) {
+ lazy.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.
+ */
+ setMetaDataAttribute(name, val) {
+ this.#settings.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.
+ */
+ setVerifiedMetaDataAttribute(name, val) {
+ this.#settings.metaData[name] = val;
+ this.#settings.metaData[this.getHashName(name)] =
+ lazy.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.
+ */
+ getMetaDataAttribute(name) {
+ return this.#settings.metaData[name] ?? undefined;
+ }
+
+ /**
+ * Gets a copy of the settings metadata.
+ *
+ * @returns {*}
+ * A copy of the settings metadata object.
+ *
+ */
+ getSettingsMetaData() {
+ return { ...this.#settings.metaData };
+ }
+
+ /**
+ * 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.
+ */
+ getVerifiedMetaDataAttribute(name) {
+ let val = this.getMetaDataAttribute(name);
+ if (
+ val &&
+ this.getMetaDataAttribute(this.getHashName(name)) !=
+ lazy.SearchUtils.getVerificationHash(val)
+ ) {
+ lazy.logConsole.warn("getVerifiedGlobalAttr, invalid hash for", name);
+ return undefined;
+ }
+ return val;
+ }
+
+ /**
+ * Sets an attribute in #settings.engines._metaData
+ *
+ * @param {string} engineName
+ * The name of the engine.
+ * @param {string} property
+ * The name of the attribute to set.
+ * @param {*} value
+ * The value to set.
+ */
+ setEngineMetaDataAttribute(engineName, property, value) {
+ let engines = [...this.#searchService._engines.values()];
+ let engine = engines.find(engine => engine._name == engineName);
+ if (engine) {
+ engine._metaData[property] = value;
+ this._delayedWrite();
+ }
+ }
+
+ /**
+ * Gets an attribute from #settings.engines._metaData
+ *
+ * @param {string} engineName
+ * The name of the engine.
+ * @param {string} property
+ * The name of the attribute to get.
+ * @returns {*}
+ * The value of the attribute, or undefined if not known.
+ */
+ getEngineMetaDataAttribute(engineName, property) {
+ let engine = this.#settings.engines.find(
+ engine => engine._name == engineName
+ );
+ return engine._metaData[property] ?? undefined;
+ }
+
+ /**
+ * Returns the name for the hash for a particular attribute. This is
+ * necessary because the default engine ID property 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) {
+ // The "current" check remains here because we need to retrieve the
+ // "current" hash name for the migration of engine ids. After the migration,
+ // the "current" property is no longer used because we now store
+ // "defaultEngineId" instead.
+ 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 lazy.SearchUtils.TOPIC_ENGINE_MODIFIED:
+ switch (verb) {
+ case lazy.SearchUtils.MODIFIED_TYPE.ADDED:
+ case lazy.SearchUtils.MODIFIED_TYPE.CHANGED:
+ case lazy.SearchUtils.MODIFIED_TYPE.REMOVED:
+ this._delayedWrite();
+ break;
+ }
+ break;
+ case lazy.SearchUtils.TOPIC_SEARCH_SERVICE:
+ switch (verb) {
+ case "init-complete":
+ case "engines-reloaded":
+ this._delayedWrite();
+ break;
+ }
+ break;
+ }
+ }
+
+ /**
+ * Compares the #settings and #cachedSettings objects.
+ *
+ * @returns {boolean}
+ * True if the objects have the same property and values.
+ */
+ isCurrentAndCachedSettingsEqual() {
+ return lazy.ObjectUtils.deepEqual(this.#settings, this.#cachedSettings);
+ }
+
+ /**
+ * This function writes to settings versions 6 and below. It does two
+ * updates:
+ * 1) Store engine ids.
+ * 2) Store "defaultEngineId" and "privateDefaultEngineId" to replace
+ * "current" and "private" because we are no longer referencing the
+ * "current" and "private" attributes with engine names as their values.
+ *
+ * @param {object} clonedSettings
+ * The SearchService holds a deep copy of the settings file object. This
+ * clonedSettings is passed in as an argument from SearchService.
+ */
+ migrateEngineIds(clonedSettings) {
+ if (clonedSettings.version <= 6) {
+ lazy.logConsole.debug("migrateEngineIds: start");
+
+ for (let engineSettings of clonedSettings.engines) {
+ let engine = this.#getEngineByName(engineSettings._name);
+
+ if (engine) {
+ // Store the engine id
+ engineSettings.id = engine.id;
+ }
+ }
+
+ let currentDefaultEngine = this.#getEngineByName(
+ clonedSettings.metaData.current
+ );
+ let privateDefaultEngine = this.#getEngineByName(
+ clonedSettings.metaData.private
+ );
+
+ // As per SearchService._getEngineDefault, we relax the verification hash
+ // check for application provided engines to reduce the annoyance for
+ // users who backup/sync their profile in custom ways.
+ if (
+ currentDefaultEngine &&
+ (currentDefaultEngine.isAppProvided ||
+ lazy.SearchUtils.getVerificationHash(
+ clonedSettings.metaData.current
+ ) == clonedSettings.metaData[this.getHashName("current")])
+ ) {
+ // Store the defaultEngineId
+ this.setVerifiedMetaDataAttribute(
+ "defaultEngineId",
+ currentDefaultEngine.id
+ );
+ } else {
+ this.setVerifiedMetaDataAttribute("defaultEngineId", "");
+ }
+
+ if (
+ privateDefaultEngine &&
+ (privateDefaultEngine.isAppProvided ||
+ lazy.SearchUtils.getVerificationHash(
+ clonedSettings.metaData.private
+ ) == clonedSettings.metaData[this.getHashName("private")])
+ ) {
+ // Store the privateDefaultEngineId
+ this.setVerifiedMetaDataAttribute(
+ "privateDefaultEngineId",
+ privateDefaultEngine.id
+ );
+ } else {
+ this.setVerifiedMetaDataAttribute("privateDefaultEngineId", "");
+ }
+
+ lazy.logConsole.debug("migrateEngineIds: done");
+ }
+ }
+
+ /**
+ * Migrates telemetry load paths for versions of settings prior to v8.
+ */
+ #migrateTelemetryLoadPaths() {
+ for (let engine of this.#settings.engines) {
+ if (!engine._loadPath) {
+ continue;
+ }
+ if (engine._loadPath.includes("set-via-policy")) {
+ engine._loadPath = "[policy]";
+ } else if (engine._loadPath.includes("set-via-user")) {
+ engine._loadPath = "[user]";
+ } else if (engine._loadPath.startsWith("[other]addEngineWithDetails:")) {
+ engine._loadPath = engine._loadPath.replace(
+ "[other]addEngineWithDetails:",
+ "[addon]"
+ );
+ }
+ }
+ }
+
+ /**
+ * Returns the engine associated with the name without SearchService
+ * initialization checks.
+ *
+ * @param {string} engineName
+ * The name of the engine.
+ * @returns {SearchEngine}
+ * The associated engine if found, null otherwise.
+ */
+ #getEngineByName(engineName) {
+ for (let engine of this.#searchService._engines.values()) {
+ if (engine.name == engineName) {
+ return engine;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/toolkit/components/search/SearchStaticData.sys.mjs b/toolkit/components/search/SearchStaticData.sys.mjs
new file mode 100644
index 0000000000..1e3513f9de
--- /dev/null
+++ b/toolkit/components/search/SearchStaticData.sys.mjs
@@ -0,0 +1,37 @@
+/* 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.
+ */
+
+// 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);
+
+export 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.sys.mjs b/toolkit/components/search/SearchSuggestionController.sys.mjs
new file mode 100644
index 0000000000..9323e5ecda
--- /dev/null
+++ b/toolkit/components/search/SearchSuggestionController.sys.mjs
@@ -0,0 +1,806 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+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 BROWSER_RICH_SUGGEST_PREF = "browser.urlbar.richSuggestions.featureGate";
+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";
+
+const SEARCH_TELEMETRY_LATENCY = "SEARCH_SUGGESTIONS_LATENCY_MS";
+
+/**
+ * Generates an UUID.
+ *
+ * @returns {string}
+ * An UUID string, without leading or trailing braces.
+ */
+function uuid() {
+ let uuid = Services.uuid.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 {object} options
+ * An object with the following properties:
+ * @param {string} [options.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} [options.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".
+ * @param {string} [options.icon]
+ * An icon representing the result in a data uri format.
+ * @param {string} [options.description]
+ * A description of the result.
+ * @param {boolean} [options.trending]
+ * Whether this is a trending suggestion.
+ */
+ constructor(value, { matchPrefix, tail, icon, description, trending } = {}) {
+ this.#value = value;
+ this.#matchPrefix = matchPrefix;
+ this.#tail = tail;
+ this.#trending = trending;
+ this.#icon = icon;
+ this.#description = description;
+ }
+
+ get value() {
+ return this.#value;
+ }
+
+ get matchPrefix() {
+ return this.#matchPrefix;
+ }
+
+ get tail() {
+ return this.#tail;
+ }
+
+ get trending() {
+ return this.#trending;
+ }
+
+ get icon() {
+ return this.#icon;
+ }
+
+ get description() {
+ return this.#description;
+ }
+
+ 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;
+ }
+
+ /**
+ * Returns true if `otherEntry` is equivalent to this instance of
+ * SearchSuggestionEntry.
+ *
+ * @param {SearchSuggestionEntry} otherEntry The entry to compare to.
+ * @returns {boolean}
+ */
+ equals(otherEntry) {
+ return otherEntry.value == this.value;
+ }
+
+ #value;
+ #matchPrefix;
+ #tail;
+ #trending;
+ #icon;
+ #description;
+}
+
+// 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();
+
+/**
+ *
+ * The SearchSuggestionController class fetches search suggestions from two
+ * sources: a remote search engine and the user's previous searches stored
+ * locally in their profile (also called "form history").
+ *
+ * The number of each suggestion type is configurable, and the controller will
+ * fetch and return both types at the same time. Instances of the class are
+ * reusable, but one instance should be used per input. The fetch() method is
+ * the main entry point. After creating an instance of the class, fetch() can
+ * be called many times to fetch suggestions.
+ *
+ */
+export class SearchSuggestionController {
+ /**
+ * Constructor
+ *
+ * @param {string} [formHistoryParam]
+ * The form history type to use with this controller.
+ */
+ constructor(formHistoryParam = DEFAULT_FORM_HISTORY_PARAM) {
+ this.formHistoryParam = formHistoryParam;
+ }
+
+ /**
+ * The maximum length of a value to be stored in search history.
+ *
+ * @type {number}
+ */
+ static SEARCH_HISTORY_MAX_VALUE_LENGTH = 255;
+
+ /**
+ * Maximum time (ms) to wait before giving up on remote suggestions
+ *
+ * @type {number}
+ */
+ static REMOTE_TIMEOUT_DEFAULT = REMOTE_TIMEOUT_DEFAULT;
+
+ /**
+ * Determines whether the given engine offers search suggestions.
+ *
+ * @param {nsISearchEngine} engine - The search engine
+ * @param {boolean} fetchTrending - Whether we should fetch trending suggestions.
+ * @returns {boolean} True if the engine offers suggestions and false otherwise.
+ */
+ static engineOffersSuggestions(engine, fetchTrending) {
+ return engine.supportsResponseType(
+ fetchTrending
+ ? lazy.SearchUtils.URL_TYPE.TRENDING_JSON
+ : lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
+ );
+ }
+
+ /**
+ * The maximum number of local form history results to return. This limit is
+ * only enforced if remote results are also returned.
+ *
+ * @type {number}
+ */
+ 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.
+ *
+ * @type {number}
+ */
+ maxRemoteResults = 10;
+
+ /**
+ * The additional parameter used when searching form history.
+ *
+ * @type {string}
+ */
+ formHistoryParam = DEFAULT_FORM_HISTORY_PARAM;
+
+ /**
+ * 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.
+ *
+ * @type {object|null}
+ */
+ formHistoryResult = null;
+
+ /**
+ * Gets the firstPartyDomains Map, useful for tests.
+ *
+ * @returns {Map} firstPartyDomains mapped by engine names.
+ */
+ get firstPartyDomains() {
+ return gFirstPartyDomains;
+ }
+
+ /**
+ * @typedef {object} FetchResult
+ * @property {Array<SearchSuggestionEntry>} local
+ * Contains local search suggestions.
+ * @property {Array<SearchSuggestionEntry>} remote
+ * Contains remote search suggestions.
+ */
+
+ /**
+ * 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
+ * @param {boolean} fetchTrending - Whether we should fetch trending suggestions.
+ *
+ * @returns {Promise<FetchResult>}
+ */
+ fetch(
+ searchTerm,
+ privateMode,
+ engine,
+ userContextId = 0,
+ restrictToEngine = false,
+ dedupeRemoteAndLocal = true,
+ fetchTrending = false
+ ) {
+ // 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 = [];
+ let context = (this.#context = {
+ awaitingLocalResults: false,
+ dedupeRemoteAndLocal,
+ engine,
+ engineId: engine?.identifier || "other",
+ fetchTrending,
+ privateMode,
+ request: null,
+ restrictToEngine,
+ searchString: searchTerm,
+ telemetryHandled: false,
+ timer: Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer),
+ userContextId,
+ });
+
+ // Fetch local results from Form History, if requested.
+ if (this.maxLocalResults && !fetchTrending) {
+ context.awaitingLocalResults = true;
+ promises.push(this.#fetchFormHistory(context));
+ }
+ // Fetch remote results from Search Service, if requested.
+ if (
+ (searchTerm || fetchTrending) &&
+ this.suggestionsEnabled &&
+ (!privateMode || this.suggestionsInPrivateBrowsingEnabled) &&
+ this.maxRemoteResults &&
+ SearchSuggestionController.engineOffersSuggestions(engine, fetchTrending)
+ ) {
+ promises.push(this.#fetchRemote(context));
+ }
+
+ function handleRejection(reason) {
+ if (reason == "HTTP request aborted") {
+ // Do nothing since this is normal.
+ return null;
+ }
+ console.error("SearchSuggestionController rejection: " + reason);
+ return null;
+ }
+ return Promise.all(promises).then(
+ results => this.#dedupeAndReturnResults(context, results),
+ 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.#context) {
+ this.#context.abort = true;
+ this.#context.request?.abort();
+ }
+ this.#context = null;
+ }
+
+ #context;
+
+ async #fetchFormHistory(context) {
+ // We don't cache these results as we assume that the in-memory SQL cache is
+ // good enough in performance.
+ let params = {
+ fieldname: this.formHistoryParam,
+ };
+
+ if (context.restrictToEngine) {
+ params.source = context.engine.name;
+ }
+
+ let results = await lazy.FormHistory.getAutoCompleteResults(
+ context.searchString,
+ params
+ );
+
+ context.awaitingLocalResults = false;
+
+ return { localResults: results };
+ }
+
+ /**
+ * Records per-engine telemetry after a search has finished.
+ *
+ * @param {object} context
+ * The search context.
+ */
+ #reportTelemetryForEngine(context) {
+ this.#reportBandwidthForEngine(context);
+
+ // Stop the latency stopwatch.
+ if (!context.telemetryHandled) {
+ if (context.abort) {
+ TelemetryStopwatch.cancelKeyed(
+ SEARCH_TELEMETRY_LATENCY,
+ context.engineId,
+ context
+ );
+ } else {
+ TelemetryStopwatch.finishKeyed(
+ SEARCH_TELEMETRY_LATENCY,
+ context.engineId,
+ context
+ );
+ }
+ context.telemetryHandled = true;
+ }
+ }
+
+ /**
+ * Report bandwidth used by search activities. It only reports when it matches
+ * search provider information.
+ *
+ * @param {object} context
+ * The search context.
+ * @param {boolean} context.abort
+ * If the request should be aborted.
+ * @param {string} context.engineId
+ * The search engine identifier.
+ * @param {object} context.request
+ * Request information
+ * @param {boolean} context.privateMode
+ * Set to true if this is coming from a private browsing mode request.
+ */
+ #reportBandwidthForEngine(context) {
+ if (context.abort || !context.request.channel) {
+ return;
+ }
+
+ let channel = ChannelWrapper.get(context.request.channel);
+ let bytesTransferred = channel.requestSize + channel.responseSize;
+ if (bytesTransferred == 0) {
+ return;
+ }
+
+ let telemetryKey = `${SEARCH_TELEMETRY_KEY_PREFIX}-${context.engineId}`;
+ if (context.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 {object} context
+ * The search context.
+ * @returns {Promise}
+ * Returns a promise that is resolved when the response is received, or
+ * rejected if there is an error.
+ */
+ #fetchRemote(context) {
+ let deferredResponse = lazy.PromiseUtils.defer();
+ let request = (context.request = new XMLHttpRequest());
+ let submission = context.engine.getSubmission(
+ context.searchString,
+ context.searchString
+ ? lazy.SearchUtils.URL_TYPE.SUGGEST_JSON
+ : lazy.SearchUtils.URL_TYPE.TRENDING_JSON
+ );
+ let method = submission.postData ? "POST" : "GET";
+ request.open(method, submission.uri.spec, true);
+ // Don't set or store cookies or on-disk cache.
+ 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(context.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(
+ context.engine.name,
+ `${context.engine.identifier || uuid()}.search.suggestions.mozilla`
+ );
+ }
+ let firstPartyDomain = gFirstPartyDomains.get(context.engine.name);
+
+ request.setOriginAttributes({
+ userContextId: context.userContextId,
+ privateBrowsingId: context.privateMode ? 1 : 0,
+ firstPartyDomain,
+ });
+
+ request.mozBackgroundRequest = true; // suppress dialogs and fail silently
+
+ context.timer.initWithCallback(
+ () => {
+ // Abort if we already got local results.
+ if (
+ request.readyState != 4 /* not complete */ &&
+ !context.awaitingLocalResults
+ ) {
+ deferredResponse.resolve("HTTP request timeout");
+ }
+ },
+ this.remoteTimeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+
+ request.addEventListener("load", () => {
+ context.timer.cancel();
+ this.#reportTelemetryForEngine(context);
+ if (!this.#context || context != this.#context || context.abort) {
+ deferredResponse.resolve(
+ "Got HTTP response after the request was cancelled"
+ );
+ return;
+ }
+ this.#onRemoteLoaded(context, deferredResponse);
+ });
+
+ request.addEventListener("error", evt => {
+ this.#reportTelemetryForEngine(context);
+ 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.
+ request.addEventListener("abort", evt => {
+ context.timer.cancel();
+ this.#reportTelemetryForEngine(context);
+ deferredResponse.reject("HTTP request aborted");
+ });
+
+ if (submission.postData) {
+ request.sendInputStream(submission.postData);
+ } else {
+ request.send();
+ }
+
+ TelemetryStopwatch.startKeyed(
+ SEARCH_TELEMETRY_LATENCY,
+ context.engineId,
+ context
+ );
+
+ return deferredResponse.promise;
+ }
+
+ /**
+ * Called when the request completed successfully (thought the HTTP status
+ * could be anything) so we can handle the response data.
+ *
+ * @param {object} context
+ * The search context.
+ * @param {Promise} deferredResponse
+ * The promise to resolve when a response is received.
+ * @private
+ */
+ #onRemoteLoaded(context, deferredResponse) {
+ let status, serverResults;
+ try {
+ status = context.request.status;
+ } catch (e) {
+ // The XMLHttpRequest can throw NS_ERROR_NOT_AVAILABLE.
+ deferredResponse.resolve("Unknown HTTP status: " + e);
+ return;
+ }
+
+ if (status != HTTP_OK || context.request.responseText == "") {
+ deferredResponse.resolve(
+ "Non-200 status or empty HTTP response: " + status
+ );
+ return;
+ }
+
+ try {
+ serverResults = JSON.parse(context.request.responseText);
+ } catch (ex) {
+ deferredResponse.resolve("Failed to parse suggestion JSON: " + ex);
+ return;
+ }
+
+ try {
+ if (
+ !Array.isArray(serverResults) ||
+ serverResults[0] == undefined ||
+ (context.searchString.localeCompare(serverResults[0], undefined, {
+ sensitivity: "base",
+ }) &&
+ // Some engines (e.g. Amazon) return a search string containing
+ // escaped Unicode sequences. Try decoding the remote search string
+ // and compare that with our typed search string.
+ context.searchString.localeCompare(
+ decodeURIComponent(
+ JSON.parse('"' + serverResults[0].replace(/\"/g, '\\"') + '"')
+ ),
+ undefined,
+ {
+ sensitivity: "base",
+ }
+ ))
+ ) {
+ // something is wrong here so drop remote results
+ deferredResponse.resolve(
+ "Unexpected response, searchString does not match remote response"
+ );
+ return;
+ }
+ } catch (ex) {
+ deferredResponse.resolve(
+ `Failed to parse the remote response string: ${ex}`
+ );
+ return;
+ }
+
+ // Remove the search string from the server results since it is no longer
+ // needed.
+ let results = serverResults.slice(1) || [];
+ deferredResponse.resolve({ result: results });
+ }
+
+ /**
+ * @param {object} context
+ * The search context.
+ * @param {Array} suggestResults - an array of result objects from different
+ * sources (local or remote).
+ * @returns {object}
+ */
+ #dedupeAndReturnResults(context, suggestResults) {
+ if (context.abort) {
+ return null;
+ }
+
+ let results = {
+ term: context.searchString,
+ remote: [],
+ local: [],
+ };
+
+ for (let resultData of suggestResults) {
+ if (typeof resultData === "string") {
+ // Failure message
+ console.error(
+ "SearchSuggestionController found an unexpected string value: " +
+ resultData
+ );
+ } else if (resultData.localResults) {
+ results.formHistoryResults = resultData.localResults;
+ results.local = resultData.localResults.map(
+ s => new SearchSuggestionEntry(s.text)
+ );
+ } 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],
+ context.fetchTrending
+ )
+ );
+ }
+ }
+ }
+
+ // 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 &&
+ context.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).
+ let maxRemoteCount = this.maxRemoteResults;
+ if (context.dedupeRemoteAndLocal) {
+ maxRemoteCount -= results.local.length;
+ }
+ results.remote = results.remote.slice(0, maxRemoteCount);
+
+ 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".
+ * @param {boolean} trending
+ * Whether the suggestion is a trending suggestion.
+ * @returns {SearchSuggestionEntry}
+ */
+ #newSearchSuggestionEntry(suggestion, richSuggestionData, trending) {
+ if (richSuggestionData && (!trending || this.richSuggestionsEnabled)) {
+ // We have valid rich suggestions.
+ let args = {
+ matchPrefix: richSuggestionData?.mp,
+ tail: richSuggestionData?.t,
+ trending,
+ };
+
+ if (this.richSuggestionsEnabled) {
+ args.icon = richSuggestionData?.i;
+ args.description = richSuggestionData?.a;
+ }
+
+ return new SearchSuggestionEntry(suggestion, args);
+ }
+ // Return a regular suggestion.
+ return new SearchSuggestionEntry(suggestion, { trending });
+ }
+}
+
+/**
+ * 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
+);
+
+/**
+ * Whether or not rich suggestions are turned on.
+ */
+XPCOMUtils.defineLazyPreferenceGetter(
+ SearchSuggestionController.prototype,
+ "richSuggestionsEnabled",
+ BROWSER_RICH_SUGGEST_PREF,
+ false
+);
diff --git a/toolkit/components/search/SearchSuggestions.sys.mjs b/toolkit/components/search/SearchSuggestions.sys.mjs
new file mode 100644
index 0000000000..16ba9668f0
--- /dev/null
+++ b/toolkit/components/search/SearchSuggestions.sys.mjs
@@ -0,0 +1,221 @@
+/* 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/. */
+
+import { FormAutoCompleteResult } from "resource://gre/modules/nsFormAutoCompleteResult.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormAutoCompleteResult: "resource://gre/modules/FormAutoComplete.sys.mjs",
+ FormHistoryClient: "resource://gre/modules/FormAutoComplete.sys.mjs",
+
+ SearchSuggestionController:
+ "resource://gre/modules/SearchSuggestionController.sys.mjs",
+});
+
+/**
+ * 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.
+ *
+ * @class
+ */
+class SuggestAutoComplete {
+ constructor() {
+ this.#suggestionController = new lazy.SearchSuggestionController();
+ this.#suggestionController.maxLocalResults = this.#historyLimit;
+ }
+
+ /**
+ * 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 {object} formHistoryResult
+ * Any previous form history result.
+ * @private
+ */
+ onResultsReady(searchString, results, formHistoryResult) {
+ if (this.#listener) {
+ let result = new FormAutoCompleteResult(
+ searchString,
+ Ci.nsIAutoCompleteResult.RESULT_SUCCESS,
+ 0,
+ "",
+ results.map(result => ({
+ value: result,
+ label: result,
+ // We supply the comments field so that autocomplete does not kick
+ // in the unescaping of the results for display which it uses for
+ // urls.
+ comment: result,
+ removable: true,
+ })),
+ 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) {
+ 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
+ ).catch(console.error);
+ return;
+ }
+
+ Services.search
+ .init()
+ .then(() => {
+ this.#triggerSearch(
+ searchString,
+ formHistorySearchParam,
+ listener,
+ privacyMode
+ ).catch(console.error);
+ })
+ .catch(result =>
+ console.error(
+ "Could not initialize search service, bailing out: " + result
+ )
+ );
+ }
+
+ /**
+ * Ends the search result gathering process. Part of nsIAutoCompleteSearch
+ * implementation.
+ */
+ stopSearch() {
+ this.#suggestionController.stop();
+ }
+
+ #suggestionController;
+
+ /**
+ * 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.
+ *
+ * @type {number}
+ */
+ #historyLimit = 7;
+
+ /**
+ * The object implementing nsIAutoCompleteObserver that we notify when
+ * we have found results.
+ *
+ * @type {object|null}
+ */
+ #listener = null;
+
+ /**
+ * 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.
+ */
+ async #triggerSearch(searchString, searchParam, listener, privacyMode) {
+ this.#listener = listener;
+ let results = await this.#suggestionController.fetch(
+ searchString,
+ privacyMode,
+ Services.search.defaultEngine
+ );
+
+ // If form history has results, add them to the list.
+ let finalResults = results.local.map(r => r.value);
+
+ // If there are remote matches, add them.
+ if (results.remote.length) {
+ // 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));
+ }
+
+ // Bug 1822297: This re-uses the wrappers from Satchel, to avoid re-writing
+ // our own nsIAutoCompleteSimpleResult implementation for now. However,
+ // we should do that at some stage to remove the dependency on satchel.
+ let client = new lazy.FormHistoryClient({
+ formField: null,
+ inputName: this.#suggestionController.formHistoryParam,
+ });
+ let formHistoryResult = new lazy.FormAutoCompleteResult(
+ client,
+ results.formHistoryResults,
+ this.#suggestionController.formHistoryParam,
+ searchString
+ );
+
+ // Notify the FE of our new results
+ this.onResultsReady(results.term, finalResults, formHistoryResult);
+ }
+
+ QueryInterface = ChromeUtils.generateQI([
+ "nsIAutoCompleteSearch",
+ "nsIAutoCompleteObserver",
+ ]);
+}
+
+/**
+ * SearchSuggestAutoComplete is a service implementation that handles suggest
+ * results specific to web searches.
+ *
+ * @class
+ */
+export class SearchSuggestAutoComplete extends SuggestAutoComplete {
+ classID = Components.ID("{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}");
+ serviceURL = "";
+}
diff --git a/toolkit/components/search/SearchUtils.sys.mjs b/toolkit/components/search/SearchUtils.sys.mjs
new file mode 100644
index 0000000000..53fb84cb09
--- /dev/null
+++ b/toolkit/components/search/SearchUtils.sys.mjs
@@ -0,0 +1,388 @@
+/* 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 */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "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;
+ _expectedContentType = null;
+ _stream = null;
+ QueryInterface = ChromeUtils.generateQI([
+ Ci.nsIRequestObserver,
+ Ci.nsIStreamListener,
+ Ci.nsIChannelEventSink,
+ Ci.nsIInterfaceRequestor,
+ Ci.nsIProgressEventSink,
+ ]);
+
+ /**
+ * Constructor
+ *
+ * @param {nsIChannel} channel
+ * The initial channel to load from.
+ * @param {RegExp} expectedContentType
+ * A regular expression to match the expected content type to.
+ * @param {Function} callback
+ * A callback to receive the loaded data. The callback is passed the bytes
+ * (array) and the content type received. The bytes argument may be null if
+ * no data could be loaded.
+ */
+ constructor(channel, expectedContentType, callback) {
+ this._channel = channel;
+ this._callback = callback;
+ this._expectedContentType = expectedContentType;
+ }
+
+ // nsIRequestObserver
+ onStartRequest(request) {
+ lazy.logConsole.debug("loadListener: Starting request:", request.name);
+ this._stream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
+ Ci.nsIBinaryInputStream
+ );
+ }
+
+ onStopRequest(request, statusCode) {
+ lazy.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) {
+ lazy.logConsole.warn("loadListener: request failed!");
+ // send null so the callback can deal with the failure
+ this._bytes = null;
+ } else if (!this._expectedContentType.test(this._channel.contentType)) {
+ lazy.logConsole.warn(
+ "loadListener: Content type does not match expected",
+ this._channel.contentType
+ );
+ this._bytes = null;
+ }
+ this._callback(this._bytes, this._bytes ? this._channel.contentType : "");
+ 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) {}
+}
+
+export 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",
+ TRENDING_JSON: "application/x-trending+json",
+ },
+
+ 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://firefox.settings.services.allizom.org/v1/buckets/main/collections/search-config/records",
+ "stage-preview":
+ "https://firefox.settings.services.allizom.org/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",
+ LOCALE: "moz:locale",
+ },
+
+ // Query parameters can have the property "purpose", whose value
+ // indicates the context that initiated a search. This list contains
+ // defined search contexts.
+ PARAM_PURPOSES: {
+ CONTEXTMENU: "contextmenu",
+ HOMEPAGE: "homepage",
+ KEYWORD: "keyword",
+ NEWTAB: "newtab",
+ SEARCHBAR: "searchbar",
+ },
+
+ LoadListener,
+
+ // This is a list of search engines that we currently consider to be "General"
+ // search, as opposed to a vertical search engine such as one used for
+ // shopping, book search, etc.
+ //
+ // Currently these are a list of hard-coded application provided ones. At some
+ // point in the future we expect to allow WebExtensions to specify by themselves,
+ // however this needs more definition on the "vertical" search terms, and the
+ // effects before we enable it.
+ GENERAL_SEARCH_ENGINE_IDS: new Set([
+ "google@search.mozilla.org",
+ "ddg@search.mozilla.org",
+ "bing@search.mozilla.org",
+ "baidu@search.mozilla.org",
+ "ecosia@search.mozilla.org",
+ "qwant@search.mozilla.org",
+ "yahoo-jp@search.mozilla.org",
+ "yandex@search.mozilla.org",
+ ]),
+
+ /**
+ * 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) {
+ lazy.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 8;
+ },
+
+ /**
+ * Indicates the channel that the build is on, with added hardening for ESR
+ * since some ESR builds may be self-built or not on the same channel.
+ *
+ * @returns {string}
+ * Returns the modified channel, with a focus on ESR if the application
+ * version is indicating ESR.
+ */
+ get MODIFIED_APP_CHANNEL() {
+ return AppConstants.IS_ESR ? "esr" : AppConstants.MOZ_UPDATE_CHANNEL;
+ },
+
+ /**
+ * 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 =
+ PathUtils.filename(PathUtils.profileDir) +
+ name +
+ disclaimer.replace(/\$appName/g, Services.appinfo.name);
+
+ let data = new TextEncoder().encode(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);
+ },
+
+ /**
+ * Tests whether the given URI is a secure OpenSearch submission URI or a
+ * secure OpenSearch update URI.
+ *
+ * Note: We don't want to count something served via localhost as insecure.
+ * We also don't want to count sites with .onion as their top-level domain
+ * as insecure because .onion URLs actually can't use https and are secured
+ * in other ways.
+ *
+ * @param {nsIURI} uri
+ * The URI to be tested.
+ * @returns {boolean}
+ * Whether the URI is secure for OpenSearch purposes.
+ */
+ isSecureURIForOpenSearch(uri) {
+ const loopbackAddresses = ["127.0.0.1", "[::1]", "localhost"];
+
+ return (
+ uri.schemeIs("https") ||
+ loopbackAddresses.includes(uri.host) ||
+ uri.host.toLowerCase().endsWith(".onion")
+ );
+ },
+};
+
+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/UserSearchEngine.sys.mjs b/toolkit/components/search/UserSearchEngine.sys.mjs
new file mode 100644
index 0000000000..5b7c5c1e88
--- /dev/null
+++ b/toolkit/components/search/UserSearchEngine.sys.mjs
@@ -0,0 +1,53 @@
+/* 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 */
+
+import { SearchEngine } from "resource://gre/modules/SearchEngine.sys.mjs";
+
+/**
+ * UserSearchEngine represents a search engine defined by a user.
+ */
+export class UserSearchEngine extends SearchEngine {
+ /**
+ * Creates a UserSearchEngine.
+ *
+ * @param {object} options
+ * The options for this search engine.
+ * @param {object} [options.details]
+ * General information about the search engine.
+ * @param {string} [options.details.name]
+ * The search engine name.
+ * @param {string} [options.details.url]
+ * The search url for the engine.
+ * @param {string} [options.details.keyword]
+ * The keyword for the engine.
+ * @param {object} [options.json]
+ * An object that represents the saved JSON settings for the engine.
+ */
+ constructor(options = {}) {
+ super({
+ loadPath: "[user]",
+ });
+
+ if (options.details) {
+ this._initWithDetails({
+ name: options.details.name,
+ search_url: encodeURI(options.details.url),
+ keyword: options.details.alias,
+ });
+ } else {
+ this._initWithJSON(options.json);
+ }
+ }
+
+ /**
+ * Returns the appropriate identifier to use for telemetry.
+ *
+ * @returns {string}
+ */
+ get telemetryId() {
+ return `other-${this.name}`;
+ }
+}
diff --git a/toolkit/components/search/components.conf b/toolkit/components/search/components.conf
new file mode 100644
index 0000000000..3e4a9b669b
--- /dev/null
+++ b/toolkit/components/search/components.conf
@@ -0,0 +1,23 @@
+# -*- 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/.
+
+Classes = [
+ {
+ 'js_name': 'search',
+ 'cid': '{7319788a-fe93-4db3-9f39-818cf08f4256}',
+ 'contract_ids': ['@mozilla.org/browser/search-service;1'],
+ 'interfaces': ['nsISearchService'],
+ 'esModule': 'resource://gre/modules/SearchService.sys.mjs',
+ 'constructor': 'SearchService',
+ 'processes': ProcessSelector.MAIN_PROCESS_ONLY,
+ },
+ {
+ 'cid': '{aa892eb4-ffbf-477d-9f9a-06c995ae9f27}',
+ 'contract_ids': ['@mozilla.org/autocomplete/search;1?name=search-autocomplete'],
+ 'esModule': 'resource://gre/modules/SearchSuggestions.sys.mjs',
+ 'constructor': 'SearchSuggestAutoComplete',
+ },
+]
diff --git a/toolkit/components/search/docs/DefaultSearchEngines.rst b/toolkit/components/search/docs/DefaultSearchEngines.rst
new file mode 100644
index 0000000000..3dfe68abb1
--- /dev/null
+++ b/toolkit/components/search/docs/DefaultSearchEngines.rst
@@ -0,0 +1,102 @@
+======================
+Default Search Engines
+======================
+
+Default Engine
+==============
+
+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 the user allows
+ the different engine to be set as default.
+* The user or Firefox (e.g. via blocklist) causes the default engine to be removed.
+
+Add-ons and Prompting for Default
+---------------------------------
+
+The prompt for selecting a search engine from an add-on as default is shown to
+the user on installation of the add-on. It may also be shown if an add-on is
+re-enabled, if the default engine was not changed in the meantime.
+
+The following diagram shows the full flow for search engines from add-ons:
+
+.. image:: ./search-add-on-prompts-flow.png
+ :align: center
+ :alt: Flowchart for prompting for default engine for Search Engines related to add-ons.
+
+When the Default Engine is Removed
+==================================
+
+If the default engine is removed by the user, or by Firefox in the case of a
+blocklist or for some other region, the new default engine is chosen by the
+following process.
+
+* If the default engine specified by the configuration for the user's region and locale
+ is visible, then it will be selected as default.
+* If there is another engine visible, fall back to the first engine identified
+ as a general search engine (see below).
+* If there are no other visible engines, unhide the region/locale default engine
+ from the configuration and set it as default if it is not the one being removed.
+* Otherwise, unhide the first general search engine, or the first visible engine.
+
+A general search engine is defined as one that returns general search results,
+for example Google or DuckDuckGo. A non-general search engine returns results
+for a specific area, e.g. shopping, books, dictionaries.
+
+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/settings/index.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/Preferences.rst b/toolkit/components/search/docs/Preferences.rst
new file mode 100644
index 0000000000..2fdf4699cb
--- /dev/null
+++ b/toolkit/components/search/docs/Preferences.rst
@@ -0,0 +1,46 @@
+Preferences
+===========
+
+This document describes Preferences affecting the toolkit Search Service.
+Preferences that are generated and updated by code won't be described here.
+
+User Exposed
+------------
+These preferences are exposed through the Firefox UI.
+
+browser.search.suggest.enabled (default: true)
+ Whether search suggestions are enabled in the search bar. Turning this off
+ also currently prevents search suggestions in the address bar.
+
+Hidden
+------
+These preferences are normally hidden, and should not be used unless you really
+know what you are doing.
+
+browser.search.log (boolean, default: false)
+ Whether search service console logging is enabled or not.
+
+browser.search.suggest.timeout (number, default: 500)
+ When requesting suggestions from a search server, it has this long to respond.
+
+browser.search.update (boolean, default: true)
+ Whether updates are enabled for OpenSearch based engines. This only applies
+ to OpenSearch engines where their definition supplies an update url.
+ Note that this does not affect any of: application updates, add-on updates,
+ remote settings updates.
+
+Experimental
+------------
+These preferences are experimental and not officially supported. They could be
+removed at any time.
+
+browser.search.separatePrivateDefault.ui.enabled (boolean, default: false)
+ Whether the UI is enabled for having a separate default search engine in
+ private browsing mode.
+
+browser.search.separatePrivateDefault (boolean, default: false)
+ Whether the user has selected to have a separate default search engine in
+ private browsing mode.
+
+browser.search.suggest.enabled.private (boolean, default: false)
+ Whether search suggestions are enabled in a private browsing window.
diff --git a/toolkit/components/search/docs/SearchConfigurationSchema.rst b/toolkit/components/search/docs/SearchConfigurationSchema.rst
new file mode 100644
index 0000000000..9ba5a3cf57
--- /dev/null
+++ b/toolkit/components/search/docs/SearchConfigurationSchema.rst
@@ -0,0 +1,586 @@
+===========================
+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``.
+
+To direct search engines to pull ``_locale`` data from a specific locale
+directory, you can use ``webExtension.locales``.
+
+For example, in this code block:
+
+.. code-block:: js
+
+ {
+ "webExtension": {
+ "id": "web@ext"
+ },
+ "appliesTo": [
+ {
+ "included": {
+ "locales": "en-US"
+ },
+ "webExtension": {
+ "locales": [
+ "us"
+ ]
+ }
+ }, {
+ "included": {
+ "locales": "en-GB"
+ },
+ "webExtension": {
+ "locales": [
+ "uk"
+ ]
+ }
+ }
+ ]
+ }
+
+There should exist a ``us`` and ``uk`` folder in the ``locales`` directory
+of the extension, ``web``.
+
+If a locale is not provided, ``webExtension.locales`` is set to
+``SearchUtils.DEFAULT_TAG``.
+
+`Search Extensions directory <https://searchfox.org/mozilla-central/source/browser/components/search/extensions>`__
+
+`Example of a locales directory <https://searchfox.org/mozilla-central/source/browser/components/search/extensions/wikipedia/_locales>`__
+
+
+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": [
+ "en-US",
+ "en-GB"
+ ]
+ }
+ },
+ "webExtension": {
+ "locales": ["$USER_LOCALE"]
+ }
+ }]
+ }
+
+Will report either ``[en-US]`` or ``[en-GB]`` as the ``webExtension.locales``
+property depending on the user's locale.
+
+Since the special string is replaced, custom folder names can be searched for
+by adding the keyword in between a consistent prefix/suffix.
+
+For example, if ``webExtension.locales`` was ``["example-$USER_LOCALE"]``,
+the locale generator will generate locale names in the form of ``example-en-US``
+and ``example-en-GB``.
+
+Note: Prior to Firefox 100.0, $USER_LOCALE used an exact match.
+In Firefox 100.0 the replacement was updated to use a standard string replacement.
+
+From Firefox 98.0.1 and 97.7.1esr, ``"$USER_LOCALE"`` may also be used in the
+``telemetryId`` field.
+
+$USER_REGION
+------------
+
+This can be used in the same situations as ``"$USER_LOCALE"``, instead
+replacing ``webExtension.locale`` with a string that uses the users region.
+
+.. code-block:: js
+
+ {
+ "webExtension": {
+ "id": "web@ext"
+ },
+ "appliesTo": [{
+ "included": {
+ "everywhere": true
+ },
+ "webExtension": {
+ "locales": ["foo-$USER_REGION"]
+ }
+ }]
+ }
+
+In this example, if the user's region is ``fr``, the ``webExtension.locale``
+will be ``foo-fr``, and the code will look for the ``messages.json`` in
+the ``foo-fr`` folder of the ``_locales`` folder for this extension.
+
+Note: ``"$USER_REGION"`` was added in Firefox 98.0.1 and 97.7.1esr and used an exact match.
+In Firefox 100.0 the replacement was updated to use a standard string replacement.
+
+"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.
+
+Starting with Firefox 96, ``"override"`` sections may include ``included`` and
+``excluded`` information which will be applied accordingly. If they are not
+supplied, then the override section will be applied to everywhere.
+
+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``.
+
+.. _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..c782f9f7c3
--- /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
+`the 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.
+
+.. _the remote settings: /services/settings/index.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/SearchEngines.rst b/toolkit/components/search/docs/SearchEngines.rst
new file mode 100644
index 0000000000..9993abf8c6
--- /dev/null
+++ b/toolkit/components/search/docs/SearchEngines.rst
@@ -0,0 +1,197 @@
+==============
+Search Engines
+==============
+This document describes the three main ways Firefox serves search engines to the
+user, enabling users to search the internet with different search providers.
+The three main ways Firefox serves search engines to the users is through:
+
+- Add-on Search Engines
+- OpenSearch Engines
+- Enterprise Policy Engines
+
+An example of a search provider is Google, which is one of the Add-on Search
+Engines described in the first section below. Another example of a search
+provider is Bugzilla, which is an OpenSearch Engine described in the second
+section below. Lastly, there are Enterprise Policy Search Engines,
+which will be the third section described in this documentation.
+
+Add-on Search Engines
+=====================
+Add-ons are additional functionality that third-party developers provide for
+users to install into Firefox. The add-on mechanism is also used by Firefox to
+ship the search engines provided by the application. To define Add-on Search
+Engines, developers use the `WebExtensions API`_. Since the WebExtensions API
+technology is used, developers interchangeably used the term WebExtension Search
+Engines when referring to Add-ons Search Engines.
+
+.. _WebExtensions API:
+ https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions
+
+The list of Add-on Search Engines provided by Firefox and their extension files
+can be found in `mozilla-central/browser/components/search/extensions
+<https://searchfox.org/mozilla-central/source/browser/components/search/extensions>`__.
+Within each Add-on Search Engine folder, there is a manifest.json file. One of the
+keys in that manifest.json file is `chrome_settings_overrides`, whose value is an object
+that describes how to construct the url, images, strings, icon, etc. Here’s an example of
+how the search provider is set within `chrome_settings_overrides`:
+
+.. code-block:: js
+
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Discogs",
+ "search_url": "https://www.discogs.com/search/?q={searchTerms}",
+ "keyword": "disc",
+ "favicon_url": "https://www.discogs.com/favicon.ico"
+ }
+ }
+
+
+To see more details on the syntax and properties, visit the `chrome settings
+overrides MDN documentation.
+<https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/
+manifest.json/chrome_settings_overrides>`__
+
+In Practice
+-----------
+All versions of Firefox support add-ons. Firefox switched over from OpenSearch
+to Add-on Search Engines internally in Firefox version 78. Add-on Search engines
+allows Firefox developers to have more flexibility and control in the
+modification of formatting search engines as we support different search
+providers.
+
+We maintain these Add-on Search Engines through a search configuration file that
+is bundled and configured via Remote Settings. As of this writing, June 2022, we
+use Remote Settings for managing search engines only for Firefox Desktop but not
+outside of Firefox Desktop.
+
+OpenSearch Engines
+===================
+OpenSearch is a plugin, software installed on Firefox to enhance capabilities
+for searching. OpenSearch has a collection of formats that describe how to
+construct the url, images, strings, icon, etc. These formats provided by
+OpenSearch allow Firefox to make a search over the internet with a specific
+search provider that is not an application provided search engine on Firefox.
+The purpose of OpenSearch is to provide more convenient ways of searching and
+different ways of searching.
+
+OpenSearch allows users to search with a vast variety of search providers which
+do not come installed with Firefox out of the box. The main benefit of OpenSearch
+is it allows site owners to easily provide users with a way to search a site.
+
+History
+-------
+Prior to OpenSearch, search plugins were first created by the `Mycroft Project
+<https://mycroftproject.com/>`__ and based off of `Sherlock
+<https://en.wikipedia.org/wiki/Sherlock_(software)>`__, a file and web search
+tool created by Apple.
+
+The OpenSearch Protocol was created and launched by A9.com in 2005. OpenSearch
+was added to Firefox version 2 in the year 2006. As of today in 2022, OpenSearch
+is a collection of formats for sharing of search results. The code is stable but
+unchanged for many years.
+
+See the `OpenSearch Documentation <https://github.com/dewitt/opensearch>`__ for
+more information on the OpenSearch formats.
+
+Autodiscovery
+-------------
+Autodiscovery is a feature on Firefox which automatically notifies the user when
+the webpage they visited has a search plugin.
+
+Here is an example of Autodiscovery from Bugzilla. You can visit
+https://bugzilla.mozilla.org and Firefox will automatically detect that the
+website has a provided search plugin. In the results dropdown, you can look at
+the search engine shortcuts section at the bottom and it will show a green plus
+sign over the Bugzilla search icon. The green plus sign indicates that the user
+can add Bugzilla as an OpenSearch Engine. After the user adds Bugzilla as an
+OpenSearch Engine, the green plus icon disappears. The user can now click the
+Bugzilla icon to make a search directly on bugzilla.mozilla.org.
+
+.. figure:: assets/bugzilla-open-search1.png
+ :alt: Image of the address bar input showing a URL
+ :scale: 28%
+ :align: center
+
+.. figure:: assets/bugzilla-open-search2.png
+ :alt: Image of the address bar input showing a URL
+ :scale: 28%
+ :align: center
+
+See the `Autodiscovery MDN Documentation <https://developer.mozilla.org/en-US/
+docs/Web/OpenSearch#autodiscovery_of_search_plugins>`__ for more information on
+Autodiscovery.
+
+Enterprise Policy Engines
+=========================
+Enterprise Policies are customizable configurations for the Firefox browser set
+by enterprises or companies who want to distribute configuration for their
+users. The idea of Enterprise Policies is to allow companies to customize Firefox
+and how their users can or cannot change the usage of Firefox based on predefined
+configuration that was set in place.
+
+Enterprise Policy Engines are search engines that a company has added as search
+engines on Firefox for their users by setting the Enterprise Policy. In this
+`Enterprise Policy Documentation
+<https://mozilla.github.io/policy-templates/#searchengines
+-this-policy-is-only-available-on-the-esr>`__,
+it outlines the different options that are available for enterprises using
+Firefox ESR (Extended Support Release) and what’s available in terms of adding,
+updating, or removing search engines. The company can use the policy to define
+which search engines are available on their Firefox ESR.
+
+See the `policy-templates
+<https://mozilla.github.io/policy-templates/>`__ for more
+information on Enterprise Policy templates and the different configuration rules
+available.
+
+Configuration
+-------------
+In practice, there are different ways for a company to specify their policy,
+depending on which operating system their machines are on. The admin can
+configure the policy on a server and when the user logs in, those configurations
+are automatically pushed to the user’s Firefox.
+
+For Windows, the `GPO (Group Policy Object)
+<https://github.com/mozilla/policy-templates/tree/master/windows>`__ or `Intune
+(Microsoft Endpoint Manager) <https://support.mozilla.org/en-US/kb/managing-firefox-intune>`__ is
+used to set the policy. For macOS, `configuration profiles
+<https://github.com/mozilla/policy-templates/tree/master/mac>`__ are created.
+For the generic case, there is a JSON file to describe the policy.
+
+When these configurations are set, Firefox takes the configuration as inputs and
+turns them into settings that Firefox can consume.
+
+A Hypothetical Use of Enterprise Policy
+---------------------------------------
+A company that is in the banking industry and requires tighter security over
+their users may not want their users to do something on Firefox without the
+company's knowledge. It may make sense for the company to disable private
+browsing for Firefox.
+
+Within a specific company, the employees of the finance department could use the
+Firefox ESR version. In this situation, we think of the finance department as
+the Firefox user rather than the individual employees as Firefox users. The
+department makes choices for the individuals that use the Firefox browser
+through the Enterprise Policy.
+
+Features On Enterprise Policy
+-----------------------------
+All Firefox versions have to honor the Enterprise Policy, but the Enterprise
+Policy may not have effect on an individual who is not using Firefox ESR at a
+company. There are features that are enterprise specific that are only available
+in ESR. These features allow search engines to be configured, allowing for
+unsigned extensions, installing search engines, and setting a default search
+engine.
+
+How To Set Up and Use an Enterprise Policy for Firefox
+------------------------------------------------------
+Install the ESR version of Firefox since Enterprise Policies are not supported on
+rapid release. Then, create the JSON file that is located in the README.md within
+https://github.com/mozilla/policy-templates. There are instructions there on how
+to configure and use the policy. Once the JSON is created with the appropriate
+settings, drop the JSON file in the directory outlined by the README.md and
+Firefox will find it and Firefox will open and run with the policy.
+
+Common formatting mistakes are often made when creating the JSON file. The JSON
+file can be validated using a JSON validator such as https://jsonlint.com/.
diff --git a/toolkit/components/search/docs/SearchService.rst b/toolkit/components/search/docs/SearchService.rst
new file mode 100644
index 0000000000..4f332ecedf
--- /dev/null
+++ b/toolkit/components/search/docs/SearchService.rst
@@ -0,0 +1,6 @@
+SearchService Reference
+=======================
+
+.. js:autoclass:: SearchService
+ :members:
+ :private-members:
diff --git a/toolkit/components/search/docs/SearchServiceHighlevelOverview.rst b/toolkit/components/search/docs/SearchServiceHighlevelOverview.rst
new file mode 100644
index 0000000000..1701f3a52b
--- /dev/null
+++ b/toolkit/components/search/docs/SearchServiceHighlevelOverview.rst
@@ -0,0 +1,85 @@
+=================================
+SearchService High-level Overview
+=================================
+``SearchService`` is the core component that manages Search Engines on the
+Firefox browser.
+
+The following diagram is a high level systems context diagram of the
+``SearchService``. The diagram illustrates which systems interface with the
+``SearchService`` so that it can do its job of managing the Search Engines.
+
+The diagram and description is an over-simplification of the ``SearchService's``
+responsibilities. It specifically highlights how the ``SearchService`` serves
+search engines to the browser on startup. However, the ``SearchService`` has
+many other responsibilities that are not outlined in the diagram such as
+maintaining and managing the Search Engines when various items change, e.g. the
+user's region or locale, configuration changes received from remote settings,
+updates received to search engine data. Nonetheless, the diagram gives a broad
+overview of the main components the ``SearchService`` interacts with most often.
+
+Diagram
+=======
+.. figure:: assets/search-service-diagram.png
+ :scale: 85%
+ :align: center
+
+Description of the Diagram
+==========================
+These steps follow the same number on the diagram. Number 1 on the diagram is
+described by number 1 below.
+
+1. When the user opens the Firefox Browser, the code starts to build the browser
+ UI components. During this startup phase, we have various systems making
+ calls to the ``SearchService``. E.g. `browser.js <https://searchfox.org/mozilla-central/rev/cb6f8d7b1f1782b9d4b2ee7312de1dcc284aaf06/browser/base/content/browser.js#3797>`_
+ calls ``Services.search.getDefault`` to fetch the default Search Engine.
+
+2. The ``SearchService`` needs information from ``System Add-ons``,
+ ``SearchSettings``, and ``Remote Settings`` to build the correct engines in
+ the right order and to return the list of engines to its callers.
+
+ a) First, the ``SearchService`` makes a request for the search configuration.
+ ``SearchService`` calls `SearchEngineSelector.fetchEngineConfiguration <https://searchfox.org/mozilla-central/rev/cb6f8d7b1f1782b9d4b2ee7312de1dcc284aaf06/toolkit/components/search/SearchService.sys.mjs#2247>`_
+ which makes a call to `Remote Settings <https://searchfox.org/mozilla-central/rev/cb6f8d7b1f1782b9d4b2ee7312de1dcc284aaf06/toolkit/components/search/SearchEngineSelector.sys.mjs#129>`_
+ for the search configuration. Remote Settings does not fetch the search
+ configuration from the remote database on startup. Instead Remote Settings
+ tries to load the :searchfox:`search configuration dump file <services/settings/dumps/main/search-config.json>`
+ from its local database and if that is empty, it will load the dump file into
+ its local database. Only after startup will Remote Settings connect to the
+ remote database when necessary. By connecting after startup, it avoids
+ a potential network request that could delay startup.
+
+ b) Second, the ``SearchService`` `fetches a JSON file <https://searchfox.org/mozilla-central/rev/cb6f8d7b1f1782b9d4b2ee7312de1dcc284aaf06/toolkit/components/search/SearchService.sys.mjs#1296-1297>`_
+ from the `SearchSettings <https://searchfox.org/mozilla-central/source/toolkit/components/search/SearchSettings.sys.mjs>`_.
+ This JSON file contains Search Engine metadata that is saved on the user's
+ computer. It's information that helps the ``SearchService`` remember the
+ user's custom settings for the Search Engines.
+
+ c) Third, the `System Add-ons <https://searchfox.org/mozilla-central/rev/cb6f8d7b1f1782b9d4b2ee7312de1dcc284aaf06/browser/components/extensions/parent/ext-chrome-settings-overrides.js#536>`_
+ passes the extension data to the ``SearchService``. At this point, the
+ ``SearchService`` only installs user installed search extensions. For the
+ Application Provided engines we create those when ``SearchService`` calls `_makeEngineFromConfig <https://searchfox.org/mozilla-central/rev/3002762e41363de8ee9ca80196d55e79651bcb6b/toolkit/components/search/SearchService.sys.mjs#3421-3440>`_.
+ Then ``_makeEngineFromConfig`` will create a new ``AddonSearchEngine``.
+ When the `AddonSearchEngine.init <https://searchfox.org/mozilla-central/rev/3002762e41363de8ee9ca80196d55e79651bcb6b/toolkit/components/search/AddonSearchEngine.sys.mjs#83-87,89>`_
+ method is called, it combines both the extension and search configuration
+ data to create the correct engine for the user based on locale, region and
+ other information.
+
+ After steps 2a, 2b, and 2c the ``SearchService`` has finished gathering
+ search engine data from ``System Add-ons``, ``SearchSettings``, and
+ ``Remote Settings``. Now the ``SearchService`` can build the different
+ types of Search Engines.
+
+3. The ``SearchService`` creates new instances of :searchfox:`SearchEngines <toolkit/components/search/SearchEngine.sys.mjs>`
+ by making an `Add-on, Open Search, or Enterprise Policy Search Engine <https://firefox-source-docs.mozilla.org/toolkit/search/SearchEngines.html>`_.
+
+4. The ``SearchService`` returns the engines to the caller that requested it.
+ E.g. the ``SearchService`` passes the default Search Engine back to
+ ``browser.js``, the system that initially requested it.
+
+Updates
+=======
+This page is up to date as of March 10, 2023. If the diagram or description
+becomes out of date, please find access to the
+``Search Service Diagram`` through the ``Firefox Search > Search Service
+Documentation`` folder in the shared drive or through this link `here <https://drive.google.com/file/d/1vKRRK87kIGt6xamHJuclkC04EKrS69Qw/view?usp=sharing>`_.
+Contributions are welcomed to keep this page up to date.
diff --git a/toolkit/components/search/docs/Telemetry.rst b/toolkit/components/search/docs/Telemetry.rst
new file mode 100644
index 0000000000..8bd66696f0
--- /dev/null
+++ b/toolkit/components/search/docs/Telemetry.rst
@@ -0,0 +1,81 @@
+=========
+Telemetry
+=========
+
+This document describes search telemetry recorded by Toolkit such as search
+service telemetry and telemetry related to fetching search suggestions.
+
+Other important search-related telemetry is recorded by Firefox and is
+documented in :doc:`/browser/search/telemetry` in the Firefox documentation.
+
+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
+
+browser.searchinit.secure_opensearch_engine_count
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Records the number of secure (i.e., using https) OpenSearch search
+ engines a given user has installed
+
+browser.searchinit.insecure_opensearch_engine_count
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Records the number of insecure (i.e., using http) OpenSearch search
+ engines a given user has installed
+
+browser.searchinit.secure_opensearch_update_count
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Records the number of OpenSearch search engines with secure updates
+ enabled (i.e., using https) a given user has installed
+
+browser.searchinit.insecure_opensearch_update_count
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ Records the number of OpenSearch search engines with insecure updates
+ enabled (i.e., using http) a given user has installed
+
+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.
+
+Histograms
+----------
+
+SEARCH_SUGGESTIONS_LATENCY_MS
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ This histogram records the latency in milliseconds of fetches to the
+ suggestions endpoints of search engines, or in other words, the time from
+ Firefox's request to a suggestions endpoint to the time Firefox receives a
+ response. It is a keyed exponential histogram with 50 buckets and values
+ between 0 and 30000 (0s and 30s). Keys in this histogram are search engine IDs
+ for built-in search engines and 'other' for non-built-in search engines.
+
+Default Search Engine
+---------------------
+
+Telemetry for the user's default search engine is currently reported via two
+systems:
+
+ 1. Legacy telemetry:
+ `Fields are reported within the telemetry environment <https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/data/environment.html#defaultsearchengine>`__
+ 2. Glean:
+ `Fields are documented in the Glean dictionary <https://dictionary.telemetry.mozilla.org/apps/firefox_desktop?search=search.engine>`__.
diff --git a/toolkit/components/search/docs/assets/bugzilla-open-search1.png b/toolkit/components/search/docs/assets/bugzilla-open-search1.png
new file mode 100644
index 0000000000..de265e5223
--- /dev/null
+++ b/toolkit/components/search/docs/assets/bugzilla-open-search1.png
Binary files differ
diff --git a/toolkit/components/search/docs/assets/bugzilla-open-search2.png b/toolkit/components/search/docs/assets/bugzilla-open-search2.png
new file mode 100644
index 0000000000..d30260d7f7
--- /dev/null
+++ b/toolkit/components/search/docs/assets/bugzilla-open-search2.png
Binary files differ
diff --git a/toolkit/components/search/docs/assets/search-service-diagram.png b/toolkit/components/search/docs/assets/search-service-diagram.png
new file mode 100644
index 0000000000..cdd244b8f4
--- /dev/null
+++ b/toolkit/components/search/docs/assets/search-service-diagram.png
Binary files differ
diff --git a/toolkit/components/search/docs/index.rst b/toolkit/components/search/docs/index.rst
new file mode 100644
index 0000000000..d8d4bc8d8f
--- /dev/null
+++ b/toolkit/components/search/docs/index.rst
@@ -0,0 +1,40 @@
+==============
+Search Service
+==============
+
+This is documentation for the Search Service.
+
+Definitions
+===========
+
+* Application-provided engines (aka app-provided): These are engines provided
+ by the application to the user as part of the configurations for the user's
+ locale/region.
+* Application default engine: The engine automatically selected by the
+ application as default, in the absence of user settings.
+* 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
+ the application default engine or an 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
+
+ SearchServiceHighlevelOverview
+ SearchEngineConfiguration
+ SearchConfigurationSchema
+ SearchEngines
+ DefaultSearchEngines
+ Preferences
+ Telemetry
+
+API Reference
+-------------
+
+.. toctree::
+
+ SearchService
diff --git a/toolkit/components/search/docs/search-add-on-prompts-flow.png b/toolkit/components/search/docs/search-add-on-prompts-flow.png
new file mode 100644
index 0000000000..ac2eeefadb
--- /dev/null
+++ b/toolkit/components/search/docs/search-add-on-prompts-flow.png
Binary files differ
diff --git a/toolkit/components/search/metrics.yaml b/toolkit/components/search/metrics.yaml
new file mode 100644
index 0000000000..e872886a6d
--- /dev/null
+++ b/toolkit/components/search/metrics.yaml
@@ -0,0 +1,367 @@
+# 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/.
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+
+search.engine.default:
+ engine_id:
+ type: string
+ description: |
+ The telemetry id of the search engine.
+ For application provided engines, this is either supplied by the
+ configuration or from the first part of the associated WebExtension Id.
+ For other engines, this is `other-<extensionName>`.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1089670
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1138503
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1755549
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+ - 'newtab'
+
+ display_name:
+ type: string
+ description: |
+ The display name of the user's default engine.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1164159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1164159#c60
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ load_path:
+ type: string
+ description: |
+ A path relating to where the search engine was installed/loaded from.
+ For example:
+ `[addon]<extension id>` for a WebExtension based
+ engine.
+ `[https]developer.mozilla.org/mdn-web-docs.xml` for an OpenSearch based
+ engine.
+ Note: this metric is truncated at 100 characters.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1164159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1164159#c60
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - web_activity
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ submission_url:
+ type: url
+ description: |
+ The submission URL of the default engine. This is only reported in the
+ cases where:
+ The engine is an application provided engine.
+ The engine has the same name as an application provided engine.
+ The engine matches one of a specific list of well known search engines.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1164159
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1164159#c60
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - web_activity
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ verified:
+ type: string
+ description: |
+ The verified status of the search engine.
+ For application provided engines, this will always be `default`.
+ For other engines this will either be `verified` or `unverified` depending
+ on if the loadPathHash is valid.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1259139
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1259139#c6
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ changed:
+ type: event
+ description: |
+ Recorded when the default search engine is changed.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1634555
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1634555
+ data_sensitivity:
+ - web_activity
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ extra_keys:
+ previous_engine_id:
+ type: string
+ description: The id of the previous default engine.
+ new_engine_id:
+ type: string
+ description: The id of the new default engine.
+ new_display_name:
+ type: string
+ description: The display name of the new default engine.
+ new_load_path:
+ type: string
+ description: |
+ The path relating to where the new default engine was installed/loaded
+ from.
+ new_submission_url:
+ type: string
+ description: |
+ The new submission URL of the new default engine. This is
+ limited to 100 characters.
+ change_source:
+ type: string
+ description: |
+ The source of the change of engine. For possible values,
+ see `DEFAULT_CHANGE_REASON` in
+ https://searchfox.org/mozilla-central/source/toolkit/components/search/SearchUtils.sys.mjs
+
+search.engine.private:
+ engine_id:
+ type: string
+ description: |
+ The telemetry id of the search engine.
+ For application provided engines, this is either supplied by the
+ configuration or from the first part of the associated WebExtension Id.
+ For other engines, this is `other-<extensionName>`.
+ If this string is an empty string (`""`), this means that one or both of
+ the preferences `browser.search.separatePrivateDefault` and
+ `browser.search.separatePrivateDefault.ui.enabled` are set to false.
+ It is possible that the user selects the same private engine as for the
+ default engine, and hence both versions of these fields will be filled in.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016#c8
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+ - 'newtab'
+
+ display_name:
+ type: string
+ description: |
+ The display name of the user's default engine.
+ If this string is an empty string (`""`), this means that one or both of
+ the preferences `browser.search.separatePrivateDefault` and
+ `browser.search.separatePrivateDefault.ui.enabled` are set to false.
+ It is possible that the user selects the same private engine as for the
+ default engine, and hence both versions of these fields will be filled in.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016#c8
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - interaction
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ load_path:
+ type: string
+ description: |
+ A path relating to where the search engine was installed/loaded from.
+ For example:
+ `[addon]<extension id>` for a WebExtension based
+ engine.
+ `[https]developer.mozilla.org/mdn-web-docs.xml` for an OpenSearch based
+ engine.
+ Note: this metric is truncated at 100 characters.
+ If this string is an empty string (`""`), this means that one or both of
+ the preferences `browser.search.separatePrivateDefault` and
+ `browser.search.separatePrivateDefault.ui.enabled` are set to false.
+ It is possible that the user selects the same private engine as for the
+ default engine, and hence both versions of these fields will be filled in.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016#c8
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - web_activity
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ submission_url:
+ type: url
+ description: |
+ The submission URL of the default engine. This is only reported in the
+ cases where:
+ The engine is an application provided engine.
+ The engine has the same name as an application provided engine.
+ The engine matches one of a specific list of well known search engines.
+ If this string is an empty string (`""`), this means that one or both of
+ the preferences `browser.search.separatePrivateDefault` and
+ `browser.search.separatePrivateDefault.ui.enabled` are set to false.
+ It is possible that the user selects the same private engine as for the
+ default engine, and hence both versions of these fields will be filled in.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016#c8
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - web_activity
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ verified:
+ type: string
+ description: |
+ The verified status of the search engine.
+ For application provided engines, this will always be `default`.
+ For other engines this will either be `verified` or `unverified` depending
+ on if the loadPathHash is valid.
+ If this string is an empty string (`""`), this means that one or both of
+ the preferences `browser.search.separatePrivateDefault` and
+ `browser.search.separatePrivateDefault.ui.enabled` are set to false.
+ It is possible that the user selects the same private engine as for the
+ default engine, and hence both versions of these fields will be filled in.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1563016#c8
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1766999
+ data_sensitivity:
+ - technical
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ lifetime: application
+ send_in_pings:
+ - 'metrics'
+
+ changed:
+ type: event
+ description: |
+ Recorded when the default search engine is changed
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1634555
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1634555
+ data_sensitivity:
+ - web_activity
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ - rev-data@mozilla.com
+ expires: never
+ extra_keys:
+ previous_engine_id:
+ type: string
+ description: The id of the previous default engine.
+ new_engine_id:
+ type: string
+ description: The id of the new default engine.
+ new_display_name:
+ type: string
+ description: The display name of the new default engine.
+ new_load_path:
+ type: string
+ description: |
+ The path relating to where the new default engine was installed/loaded
+ from.
+ new_submission_url:
+ type: string
+ description: |
+ The new submission URL of the new default engine. This is
+ limited to 100 characters.
+ change_source:
+ type: string
+ description: |
+ The source of the change of engine. For possible values,
+ see `REASON_CHANGE_MAP` in
+ https://searchfox.org/mozilla-central/source/toolkit/components/search/SearchService.sys.mjs
+
+search.service:
+ startup_time:
+ type: timing_distribution
+ time_unit: nanosecond
+ description: |
+ The time duration it takes for the search service to start up.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832509
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1832509#c3
+ notification_emails:
+ - fx-search-telemetry@mozilla.com
+ data_sensitivity:
+ - technical
+ expires: never
+ telemetry_mirror: SEARCH_SERVICE_INIT2_MS
diff --git a/toolkit/components/search/moz.build b/toolkit/components/search/moz.build
new file mode 100644
index 0000000000..bfed381dfe
--- /dev/null
+++ b/toolkit/components/search/moz.build
@@ -0,0 +1,44 @@
+# -*- 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"
+
+EXTRA_JS_MODULES += [
+ "AddonSearchEngine.sys.mjs",
+ "OpenSearchEngine.sys.mjs",
+ "PolicySearchEngine.sys.mjs",
+ "SearchEngine.sys.mjs",
+ "SearchEngineSelector.sys.mjs",
+ "SearchService.sys.mjs",
+ "SearchSettings.sys.mjs",
+ "SearchStaticData.sys.mjs",
+ "SearchSuggestionController.sys.mjs",
+ "SearchSuggestions.sys.mjs",
+ "SearchUtils.sys.mjs",
+ "UserSearchEngine.sys.mjs",
+]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+TESTING_JS_MODULES += [
+ "tests/SearchTestUtils.sys.mjs",
+]
+
+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..c70f85b5dd
--- /dev/null
+++ b/toolkit/components/search/nsISearchService.idl
@@ -0,0 +1,549 @@
+/* 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 searchTerms
+ * The search term(s) for the submission.
+ *
+ * @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 that indicates the context of the search request. This may then
+ * be used to provide different submission data depending on the context.
+ *
+ * @returns nsISearchSubmission
+ * The submission data. If no appropriate submission can be determined for
+ * the request type, this may be null.
+ */
+ nsISearchSubmission getSubmission(in AString searchTerms,
+ [optional] in AString responseType,
+ [optional] in AString purpose);
+ /**
+ * Returns the search term of a possible search result URI if and only if:
+ * - The URI has the same scheme, host, and path as the engine.
+ * - All query parameters of the URI have a matching name and value in the engine.
+ * - An exception to the equality check is the engine's termsParameterName
+ * value, which contains a placeholder, i.e. {searchTerms}.
+ * - If an engine has query parameters with "null" values, they will be ignored.
+ *
+ * @param uri
+ * A URI that may or may not be from a search result matching the engine.
+ *
+ * @returns A string representing the termsParameterName value of the URI,
+ * or an empty string if the URI isn't matched to the engine.
+ */
+ AString searchTermFromResult(in nsIURI uri);
+
+ /**
+ * 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 not an empty string, 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;
+
+ /**
+ * The display of the search engine id. This is a unique identifier.
+ */
+ readonly attribute AString id;
+
+ /**
+ * The searchForm URL points to the engine's organic search page. This should
+ * not contain neither search term parameters nor partner codes, but may
+ * contain parameters which set the engine in the correct way.
+ *
+ * This URL is typically the prePath and filePath of the search submission URI,
+ * but may vary for different engines. For example, some engines may use a
+ * different domain, e.g. https://sub.example.com for the search URI but
+ * https://example.org/ for the organic search page.
+ */
+ 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 engine is provided by the application, e.g. it is
+ * in the list of configured search engines.
+ */
+ readonly attribute boolean isAppProvided;
+
+ /**
+ * Whether or not this engine is an in-memory only search engine.
+ * These engines are typically application provided or policy engines,
+ * where they are loaded every time on SearchService initialization
+ * using the policy JSON or the extension manifest. Minimal details of the
+ * in-memory engines are saved to disk, but they are never loaded
+ * from the user's saved settings file.
+ */
+ readonly attribute boolean inMemory;
+
+ /**
+ * Whether or not this engine is a "general" search engine, e.g. is it for
+ * generally searching the web, or does it have a specific purpose like
+ * shopping.
+ */
+ readonly attribute boolean isGeneralPurposeEngine;
+
+ /**
+ * The domain from which search results are returned for this engine.
+ *
+ * @return the domain of the the search URL.
+ */
+readonly attribute AString searchUrlDomain;
+
+};
+
+[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;
+};
+
+[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;
+
+ const unsigned short CHANGE_REASON_UNKNOWN = 0x0;
+ const unsigned short CHANGE_REASON_USER = 0x1;
+ const unsigned short CHANGE_REASON_USER_PRIVATE_SPLIT = 0x2;
+ const unsigned short CHANGE_REASON_USER_SEARCHBAR = 0x3;
+ const unsigned short CHANGE_REASON_USER_SEARCHBAR_CONTEXT = 0x4;
+ const unsigned short CHANGE_REASON_ADDON_INSTALL = 0x5;
+ const unsigned short CHANGE_REASON_ADDON_UNINSTALL = 0x6;
+ const unsigned short CHANGE_REASON_CONFIG = 0x7;
+ const unsigned short CHANGE_REASON_LOCALE = 0x8;
+ const unsigned short CHANGE_REASON_REGION = 0x9;
+ const unsigned short CHANGE_REASON_EXPERIMENT = 0xA;
+ const unsigned short CHANGE_REASON_ENTERPRISE = 0xB;
+ const unsigned short CHANGE_REASON_UITOUR = 0xC;
+
+ /**
+ * 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;
+
+ /**
+ * Determine whether initialization has been completed successfully.
+ *
+ */
+ readonly attribute bool hasSuccessfullyInitialized;
+
+
+ /**
+ * Runs background checks; Designed to be run on idle.
+ */
+ Promise runBackgroundChecks();
+
+ /**
+ * Resets the default engine to its app default engine value.
+ */
+ Promise resetToAppDefaultEngine();
+
+ /**
+ * 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 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);
+
+ /**
+ * 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 engine with the specified Id.
+ *
+ * @param aEngineId
+ * The Id of the engine.
+ * @returns The corresponding nsISearchEngine object, or null if it doesn't
+ * exist.
+ */
+ nsISearchEngine getEngineById(in AString aEngineId);
+
+ /**
+ * 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 Application Default Engine object that is the default for this region,
+ * ignoring changes the user may have subsequently made.
+ */
+ readonly attribute nsISearchEngine appDefaultEngine;
+
+ /**
+ * The Application Default 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 appPrivateDefaultEngine;
+
+ /**
+ * 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 application default engine if it's not hidden, then to
+ * the first visible engine, and as a last resort it will unhide the app
+ * default engine.
+ */
+ attribute nsISearchEngine defaultEngine;
+
+ Promise getDefault();
+ Promise setDefault(in nsISearchEngine engine, in unsigned short changeSource);
+
+ /**
+ * The currently active search engine for private browsing mode.
+ * @see defaultEngine.
+ */
+ attribute nsISearchEngine defaultPrivateEngine;
+
+ Promise getDefaultPrivate();
+ Promise setDefaultPrivate(
+ in nsISearchEngine engine,
+ in unsigned short changeSource);
+
+ /**
+ * Whether to display the "Search in Private Window" result in the urlbar.
+ */
+ readonly attribute boolean separatePrivateDefaultUrlbarResultEnabled;
+
+ /**
+ * 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
+ */
+ jsval 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..43ff8e50c2
--- /dev/null
+++ b/toolkit/components/search/schema/search-default-override-allowlist-schema.json
@@ -0,0 +1,49 @@
+{
+ "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..1b85489c13
--- /dev/null
+++ b/toolkit/components/search/schema/search-default-override-allowlist-ui-schema.json
@@ -0,0 +1,3 @@
+{
+ "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..838ccbf340
--- /dev/null
+++ b/toolkit/components/search/schema/search-engine-config-schema.json
@@ -0,0 +1,400 @@
+{
+ "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 search telemetry."
+ },
+ "webExtension": {
+ "$ref": "#/definitions/webExtension"
+ },
+ "urls": {
+ "$ref": "#/definitions/urls"
+ },
+ "params": {
+ "$ref": "#/definitions/params"
+ },
+ "extraParams": {
+ "$ref": "#/definitions/extraParams"
+ },
+ "suggestExtraParams": {
+ "$ref": "#/definitions/extraParams"
+ }
+ },
+ "definitions": {
+ "application": {
+ "type": "object",
+ "title": "Application Details",
+ "properties": {
+ "name": {
+ "type": "array",
+ "title": "Name",
+ "description": "The application(s) this applies to (default/not specified is everywhere)",
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z]{0,100}$",
+ "enum": ["", "firefox"]
+ },
+ "uniqueItems": true
+ },
+ "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",
+ "pattern": "^[a-z]{0,100}$",
+ "enum": ["default", "nightly", "aurora", "beta", "release", "esr"]
+ },
+ "uniqueItems": true
+ },
+ "distributions": {
+ "type": "array",
+ "title": "Distributions",
+ "description": "Which distributions this applies to.",
+ "items": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9.-]{0,100}$"
+ },
+ "uniqueItems": true
+ },
+ "excludedDistributions": {
+ "type": "array",
+ "title": "Excluded Distributions",
+ "description": "Which distributions this does not apply to.",
+ "items": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9.-]{0,100}$"
+ },
+ "uniqueItems": true
+ },
+ "minVersion": {
+ "type": "string",
+ "title": "Minimum Version",
+ "pattern": "^[0-9a-z.]{0,20}$",
+ "description": "The minimum version this applies to"
+ },
+ "maxVersion": {
+ "type": "string",
+ "title": "Maxium Version",
+ "pattern": "^[0-9a-z.]{0,20}$",
+ "description": "The maximum version this applies to (less-than comparison)"
+ }
+ }
+ },
+ "default": {
+ "type": "string",
+ "title": "Default Status",
+ "pattern": "^[a-z-]{0,20}$",
+ "description": "Whether or not this engine should be default.",
+ "enum": ["yes", "yes-if-no-other", "no"]
+ },
+ "defaultPrivate": {
+ "type": "string",
+ "title": "Default Status (PBM)",
+ "pattern": "^[a-z-]{0,20}$",
+ "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",
+ "pattern": "^[a-z]{0,20}$",
+ "description": "Name of the parameter that will be used in the search query"
+ },
+ "condition": {
+ "type": "string",
+ "title": "Condition",
+ "pattern": "^[a-z]{0,10}$",
+ "description": "The type of parameter (pref or purpose)",
+ "enum": ["pref", "purpose"]
+ },
+ "purpose": {
+ "type": "string",
+ "title": "Purpose",
+ "pattern": "^[a-z{}]{0,100}$",
+ "description": "The search purpose that triggers this parameter being appended.",
+ "enum": [
+ "searchbar",
+ "keyword",
+ "contextmenu",
+ "homepage",
+ "newtab"
+ ]
+ },
+ "value": {
+ "type": "string",
+ "title": "Value",
+ "pattern": "^[a-zA-Z0-9_]{0,100}$",
+ "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",
+ "pattern": "^[a-z0-9_]{0,100}$",
+ "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)"
+ },
+ "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",
+ "pattern": "^[a-zA-Z0-9.-]{0,100}$",
+ "description": "Name of the parameter that will be used in the query"
+ },
+ "value": {
+ "type": "string",
+ "title": "Value",
+ "pattern": "^[a-zA-Z0-9_{}:/.-]{0,100}$",
+ "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"
+ }
+ }
+ },
+ "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",
+ "pattern": "^[a-zA-Z0-9-$_]{0,100}$",
+ "description": "The telemetry Id as used for search 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$"
+ },
+ "locales": {
+ "type": "array",
+ "title": "WebExtension Locales",
+ "description": "Overrides the WebExtension locales and specifies to use a particular one. Ideally this should only be used when really necessary, otherwise considered deprecated.",
+ "items": {
+ "type": "string",
+ "pattern": "^[a-zA-Z0-9-$_]{0,100}$"
+ }
+ }
+ }
+ },
+ "urls": {
+ "type": "object",
+ "description": "Urls pertaining to this engine",
+ "properties": {
+ "trending": {
+ "type": "object",
+ "description": "Details of the url used to fetch trending suggestions",
+ "properties": {
+ "fullPath": {
+ "type": "string",
+ "title": "fullPath",
+ "format": "uri",
+ "description": "The url used to retrieve trending suggestions"
+ },
+ "query": {
+ "type": "string",
+ "title": "query",
+ "description": "The query parameters to send in a trending suggestions request",
+ "pattern": "^[a-zA-Z0-9.={}&-]{0,100}$"
+ }
+ }
+ }
+ }
+ },
+ "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"
+ },
+ "urls": {
+ "$ref": "#/definitions/urls"
+ },
+ "sendAttributionRequest": {
+ "$ref": "#/definitions/sendAttributionRequest"
+ },
+ "telemetryId": {
+ "$ref": "#/definitions/telemetryId"
+ },
+ "params": {
+ "$ref": "#/definitions/params"
+ },
+ "extraParams": {
+ "$ref": "#/definitions/extraParams"
+ },
+ "experiment": {
+ "type": "string",
+ "title": "Experiment",
+ "pattern": "^[a-zA-Z0-9-]{0,100}$",
+ "description": "The experiment this section is associated with, if blank it is associated with any configuration."
+ },
+ "override": {
+ "type": "boolean",
+ "title": "Override",
+ "description": "This section will override previous appliesTo sections, but not add new locations where this engine is deployed to."
+ }
+ }
+ }
+ }
+}
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..0b8b8d0dd6
--- /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",
+ "suggestExtraParams",
+ "appliesTo"
+ ]
+}
diff --git a/toolkit/components/search/tests/SearchTestUtils.sys.mjs b/toolkit/components/search/tests/SearchTestUtils.sys.mjs
new file mode 100644
index 0000000000..a2dc9b9e01
--- /dev/null
+++ b/toolkit/components/search/tests/SearchTestUtils.sys.mjs
@@ -0,0 +1,505 @@
+import { MockRegistrar } from "resource://testing-common/MockRegistrar.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+ ExtensionTestUtils:
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+var gTestScope;
+
+export var SearchTestUtils = {
+ init(testScope) {
+ gTestScope = testScope;
+ this._isMochitest = !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR");
+ if (this._isMochitest) {
+ this._isMochitest = true;
+ lazy.AddonTestUtils.initMochitest(testScope);
+ } else {
+ this._isMochitest = false;
+ // This handles xpcshell-tests.
+ gTestScope.ExtensionTestUtils = lazy.ExtensionTestUtils;
+ this.initXPCShellAddonManager(testScope);
+ }
+ },
+
+ /**
+ * Adds an OpenSearch based engine to the search service. It will remove
+ * the engine at the end of the test.
+ *
+ * @param {object} options
+ * The options for the new search engine.
+ * @param {string} options.url
+ * The URL of the engine to add.
+ * @param {boolean} [options.setAsDefault]
+ * Whether or not to set the engine as default automatically. If this is
+ * true, the engine will be set as default, and the previous default engine
+ * will be restored when the test exits.
+ * @param {boolean} [options.setAsDefaultPrivate]
+ * Whether or not to set the engine as default automatically for private mode.
+ * If this is true, the engine will be set as default, and the previous default
+ * engine will be restored when the test exits.
+ * @returns {Promise} Returns a promise that is resolved with the new engine
+ * or rejected if it fails.
+ */
+ async promiseNewSearchEngine({
+ url,
+ setAsDefault = false,
+ setAsDefaultPrivate = false,
+ }) {
+ // OpenSearch engines can only be added via http protocols.
+ url = url.replace("chrome://mochitests/content", "https://example.com");
+ let engine = await Services.search.addOpenSearchEngine(url, "");
+ let previousEngine = Services.search.defaultEngine;
+ let previousPrivateEngine = Services.search.defaultPrivateEngine;
+ if (setAsDefault) {
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ if (setAsDefaultPrivate) {
+ await Services.search.setDefaultPrivate(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ gTestScope.registerCleanupFunction(async () => {
+ if (setAsDefault) {
+ await Services.search.setDefault(
+ previousEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ if (setAsDefaultPrivate) {
+ await Services.search.setDefaultPrivate(
+ previousPrivateEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ try {
+ await Services.search.removeEngine(engine);
+ } catch (ex) {
+ // Don't throw if the test has already removed it.
+ }
+ });
+ 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 lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
+ if (config) {
+ return lazy.sinon.stub(settings, "get").returns(config);
+ }
+
+ let response = await fetch(`resource://search-extensions/engines.json`);
+ let json = await response.json();
+ return lazy.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
+ * An array of engine configurations.
+ */
+ 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 =
+ lazy.AddonManager.SCOPE_PROFILE | lazy.AddonManager.SCOPE_APPLICATION;
+ Services.prefs.setIntPref("extensions.enabledScopes", scopes);
+ // Only do this once.
+ try {
+ gTestScope.ExtensionTestUtils.init(scope);
+ } catch (ex) {
+ // This can happen if init is called twice.
+ if (ex.result != Cr.NS_ERROR_FILE_ALREADY_EXISTS) {
+ throw ex;
+ }
+ }
+ lazy.AddonTestUtils.usePrivilegedSignatures = usePrivilegedSignatures;
+ lazy.AddonTestUtils.overrideCertDB();
+ },
+
+ /**
+ * Add a search engine as a WebExtension.
+ *
+ * Note: for tests, the extension must generally be unloaded before
+ * `registerCleanupFunction`s are triggered. See bug 1694409.
+ *
+ * This function automatically registers an unload for the extension, this
+ * may be skipped with the skipUnload argument.
+ *
+ * @param {object} [manifest]
+ * See {@link createEngineManifest}
+ * @param {object} [options]
+ * Options for how the engine is installed and uninstalled.
+ * @param {boolean} [options.setAsDefault]
+ * Whether or not to set the engine as default automatically. If this is
+ * true, the engine will be set as default, and the previous default engine
+ * will be restored when the test exits.
+ * @param {boolean} [options.setAsDefaultPrivate]
+ * Whether or not to set the engine as default automatically for private mode.
+ * If this is true, the engine will be set as default, and the previous default
+ * engine will be restored when the test exits.
+ * @param {boolean} [options.skipUnload]
+ * If true, this will skip the automatic unloading of the extension.
+ * @param {object} [files]
+ * A key value object where the keys are the filenames and their contents are
+ * the values. Used for simulating locales and other files in the WebExtension.
+ * @returns {object}
+ * The loaded extension.
+ */
+ async installSearchExtension(
+ manifest = {},
+ {
+ setAsDefault = false,
+ setAsDefaultPrivate = false,
+ skipUnload = false,
+ } = {},
+ files = {}
+ ) {
+ await Services.search.init();
+
+ let extensionInfo = {
+ useAddonManager: "permanent",
+ files,
+ manifest: this.createEngineManifest(manifest),
+ };
+
+ let extension;
+
+ let previousEngine = Services.search.defaultEngine;
+ let previousPrivateEngine = Services.search.defaultPrivateEngine;
+
+ async function cleanup() {
+ if (setAsDefault) {
+ await Services.search.setDefault(
+ previousEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ if (setAsDefaultPrivate) {
+ await Services.search.setDefaultPrivate(
+ previousPrivateEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ await extension.unload();
+ }
+
+ // Cleanup must be registered before loading the extension to avoid
+ // failures for mochitests.
+ if (!skipUnload && this._isMochitest) {
+ gTestScope.registerCleanupFunction(cleanup);
+ }
+
+ extension = gTestScope.ExtensionTestUtils.loadExtension(extensionInfo);
+ await extension.startup();
+ await lazy.AddonTestUtils.waitForSearchProviderStartup(extension);
+ let engine = Services.search.getEngineByName(manifest.name);
+
+ if (setAsDefault) {
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ if (setAsDefaultPrivate) {
+ await Services.search.setDefaultPrivate(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+
+ // For xpcshell-tests we must register the unload after adding the extension.
+ // See bug 1694409 for why this is.
+ if (!skipUnload && !this._isMochitest) {
+ gTestScope.registerCleanupFunction(cleanup);
+ }
+
+ return extension;
+ },
+
+ /**
+ * Install a search engine as a system extension to simulate
+ * Normandy updates. For xpcshell-tests only.
+ *
+ * @param {object} [options]
+ * See {@link createEngineManifest}
+ */
+ async installSystemSearchExtension(options = {}) {
+ options.id = (options.id ?? "example") + "@search.mozilla.org";
+ let xpi = await lazy.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 lazy.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]
+ * The options for the manifest.
+ * @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.favicon_url]
+ * The favicon to use for the search engine in the WebExtension.
+ * @param {string} [options.keyword]
+ * The keyword to use for the search engine.
+ * @param {string} [options.encoding]
+ * The encoding to use for the search engine.
+ * @param {string} [options.search_url]
+ * The search URL to use for the search engine.
+ * @param {string} [options.search_url_get_params]
+ * The GET search URL parameters to use for the search engine
+ * @param {string} [options.search_url_post_params]
+ * The POST search URL parameters to use for the search engine
+ * @param {string} [options.suggest_url]
+ * The suggestion URL to use for the search engine.
+ * @param {string} [options.suggest_url_get_params]
+ * The suggestion URL parameters to use for the search engine.
+ * @param {string} [options.search_form]
+ * The search form to use for the search engine.
+ * @returns {object}
+ * The generated manifest.
+ */
+ createEngineManifest(options = {}) {
+ options.name = options.name ?? "Example";
+ options.id = options.id ?? options.name.toLowerCase().replaceAll(" ", "");
+ if (!options.id.includes("@")) {
+ options.id += "@tests.mozilla.org";
+ }
+ options.version = options.version ?? "1.0";
+ let manifest = {
+ version: options.version,
+ browser_specific_settings: {
+ gecko: {
+ id: options.id,
+ },
+ },
+ chrome_settings_overrides: {
+ search_provider: {
+ name: options.name,
+ search_url: options.search_url ?? "https://example.com/",
+ },
+ },
+ };
+
+ if (options.default_locale) {
+ manifest.default_locale = options.default_locale;
+ }
+
+ if (options.search_url_post_params) {
+ manifest.chrome_settings_overrides.search_provider.search_url_post_params =
+ options.search_url_post_params;
+ } else {
+ manifest.chrome_settings_overrides.search_provider.search_url_get_params =
+ options.search_url_get_params ?? "?q={searchTerms}";
+ }
+
+ if (options.favicon_url) {
+ manifest.chrome_settings_overrides.search_provider.favicon_url =
+ options.favicon_url;
+ }
+ 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;
+ }
+ if (options.search_form) {
+ manifest.chrome_settings_overrides.search_provider.search_form =
+ options.search_form;
+ }
+ if (options.favicon_url) {
+ manifest.chrome_settings_overrides.search_provider.favicon_url =
+ options.favicon_url;
+ }
+ 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.
+ */
+ 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 = lazy.RemoteSettings(lazy.SearchUtils.SETTINGS_KEY);
+ config = await settings.get();
+ }
+ const reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+ await lazy.RemoteSettings(lazy.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
new file mode 100644
index 0000000000..f22522411d
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/bigIcon.ico
Binary files differ
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..14bc27bdd4
--- /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",
+ "browser_specific_settings": {
+ "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/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-chromeicon/manifest.json
new file mode 100644
index 0000000000..d2896b78f8
--- /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",
+ "browser_specific_settings": {
+ "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-diff-name/_locales/en/messages.json b/toolkit/components/search/tests/xpcshell/data/engine-diff-name/_locales/en/messages.json
new file mode 100644
index 0000000000..07580b8ea5
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engine-diff-name/_locales/en/messages.json
@@ -0,0 +1,8 @@
+{
+ "extensionName": {
+ "message": "engine-diff-name-en"
+ },
+ "searchUrl": {
+ "message": "https://en.wikipedia.com/search"
+ }
+}
diff --git a/toolkit/components/search/tests/xpcshell/data/engine-diff-name/_locales/gd/messages.json b/toolkit/components/search/tests/xpcshell/data/engine-diff-name/_locales/gd/messages.json
new file mode 100644
index 0000000000..de01d16bf0
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engine-diff-name/_locales/gd/messages.json
@@ -0,0 +1,8 @@
+{
+ "extensionName": {
+ "message": "engine-diff-name-gd"
+ },
+ "searchUrl": {
+ "message": "https://gd.wikipedia.com/search"
+ }
+}
diff --git a/toolkit/components/search/tests/xpcshell/data/engine-diff-name/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-diff-name/manifest.json
new file mode 100644
index 0000000000..3c80765f61
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engine-diff-name/manifest.json
@@ -0,0 +1,21 @@
+{
+ "name": "engine-diff-name",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "engine-diff-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-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..cc895d26d9
--- /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",
+ "browser_specific_settings": {
+ "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..a894be0a41
--- /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",
+ "browser_specific_settings": {
+ "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..a876520bc2
--- /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",
+ "browser_specific_settings": {
+ "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-purposes/manifest.json b/toolkit/components/search/tests/xpcshell/data/engine-purposes/manifest.json
new file mode 100644
index 0000000000..fc709063e1
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engine-purposes/manifest.json
@@ -0,0 +1,62 @@
+{
+ "name": "Test Engine With Purposes",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "engine-purposes@search.mozilla.org"
+ }
+ },
+ "description": "A test search engine with purposes",
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "Test Engine With Purposes",
+ "search_url": "https://www.example.com/search",
+ "params": [
+ {
+ "name": "form",
+ "condition": "purpose",
+ "purpose": "keyword",
+ "value": "MOZKEYWORD"
+ },
+ {
+ "name": "form",
+ "condition": "purpose",
+ "purpose": "contextmenu",
+ "value": "MOZCONTEXT"
+ },
+ {
+ "name": "form",
+ "condition": "purpose",
+ "purpose": "newtab",
+ "value": "MOZNEWTAB"
+ },
+ {
+ "name": "form",
+ "condition": "purpose",
+ "purpose": "searchbar",
+ "value": "MOZSEARCHBAR"
+ },
+ {
+ "name": "form",
+ "condition": "purpose",
+ "purpose": "homepage",
+ "value": "MOZHOMEPAGE"
+ },
+ {
+ "name": "pc",
+ "value": "FIREFOX"
+ },
+ {
+ "name": "channel",
+ "condition": "pref",
+ "pref": "testChannelEnabled"
+ },
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ]
+ }
+ }
+}
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..ed4a609e7c
--- /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",
+ "browser_specific_settings": {
+ "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..cc3fc95430
--- /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",
+ "browser_specific_settings": {
+ "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/_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..dc62336145
--- /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",
+ "browser_specific_settings": {
+ "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..dc8a4c5d45
--- /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",
+ "browser_specific_settings": {
+ "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..d268af8eab
--- /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",
+ "browser_specific_settings": {
+ "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&amp;client=firefox&amp;hl={moz:locale}&amp;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..30d221f388
--- /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",
+ "browser_specific_settings": {
+ "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..7dd4b15931
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engine2/manifest.json
@@ -0,0 +1,18 @@
+{
+ "name": "A second test engine",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "engine2@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "description": "A second test search engine (based on DuckDuckGo)",
+ "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..b5366a6eb6
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engineImages/manifest.json
@@ -0,0 +1,37 @@
+{
+ "name": "IconsTest",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "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..4b32003dcf
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engineMaker.sjs
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Dynamically create an OpenSearch search engine offering search suggestions
+ * via searchSuggestions.sjs.
+ *
+ * The engine is constructed by passing a JSON object with engine details 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.
+ *
+ * @param {Response} response
+ * The response object to write the engine to.
+ * @param {object} engineData
+ * Information about the search engine to write to the response.
+ */
+function createOpenSearchEngine(response, engineData) {
+ let params = "";
+ let 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 updateFile = "";
+ if (engineData.updateFile) {
+ updateFile = `<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="${engineData.baseURL}${engineData.updateFile}" />
+ `;
+ }
+
+ 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}'/>
+ ${updateFile}
+</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..85c89fb388
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engines-no-order-hint.json
@@ -0,0 +1,83 @@
+{
+ "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..01bcfd1d05
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/engines.json
@@ -0,0 +1,98 @@
+{
+ "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
new file mode 100644
index 0000000000..4314071e24
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/geolookup-extensions/multilocale/favicon.ico
Binary files differ
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..0fd835ca40
--- /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",
+ "browser_specific_settings": {
+ "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/iconsRedirect.sjs b/toolkit/components/search/tests/xpcshell/data/iconsRedirect.sjs
new file mode 100644
index 0000000000..e08fb0b65a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/iconsRedirect.sjs
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Redirect a request for an icon to a different place, using a different
+ * content-type.
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Moved");
+ if (request.queryString == "type=invalid") {
+ response.setHeader("Content-Type", "image/png", false);
+ response.setHeader("Location", "engine.xml", false);
+ } else {
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Location", "remoteIcon.ico", false);
+ }
+}
diff --git a/toolkit/components/search/tests/xpcshell/data/remoteIcon.ico b/toolkit/components/search/tests/xpcshell/data/remoteIcon.ico
new file mode 100644
index 0000000000..442ab4dc80
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/remoteIcon.ico
Binary files differ
diff --git a/toolkit/components/search/tests/xpcshell/data/search-legacy-correct-default-engine-hashes.json b/toolkit/components/search/tests/xpcshell/data/search-legacy-correct-default-engine-hashes.json
new file mode 100644
index 0000000000..e6091b7230
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-legacy-correct-default-engine-hashes.json
@@ -0,0 +1,112 @@
+{
+ "version": 1,
+ "buildID": "20121106",
+ "locale": "en-US",
+ "metaData": {
+ "current": "engine2",
+ "private": "engine2"
+ },
+ "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": false
+ },
+ "_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-legacy-no-ids.json b/toolkit/components/search/tests/xpcshell/data/search-legacy-no-ids.json
new file mode 100644
index 0000000000..733c323876
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-legacy-no-ids.json
@@ -0,0 +1,87 @@
+{
+ "version": 6,
+ "engines": [
+ { "_name": "Google", "_isAppProvided": true, "_metaData": { "order": 1 } },
+ {
+ "_name": "Wikipedia (en)",
+ "_isAppProvided": true,
+ "_metaData": { "order": 7 }
+ },
+ { "_name": "Bing", "_isAppProvided": true, "_metaData": { "order": 3 } },
+ {
+ "_name": "Amazon.co.uk",
+ "_isAppProvided": true,
+ "_metaData": { "order": 2 }
+ },
+ {
+ "_name": "DuckDuckGo",
+ "_isAppProvided": true,
+ "_metaData": { "order": 4 }
+ },
+ { "_name": "eBay", "_isAppProvided": true, "_metaData": { "order": 5 } },
+ {
+ "_name": "Policy",
+ "_loadPath": "[other]addEngineWithDetails:set-via-policy",
+ "_metaData": { "alias": "PolicyAlias", "order": 6 }
+ },
+ {
+ "_name": "Bugzilla@Mozilla",
+ "_loadPath": "[https]bugzilla.mozilla.org/bugzillamozilla.xml",
+ "description": "Bugzilla@Mozilla Quick Search",
+ "_metaData": {
+ "loadPathHash": "Bxz6jVe3IIBxLLaafUus536LMyLKoGZm7xsBv/yiTw8=",
+ "order": 8,
+ "alias": "bugzillaAlias"
+ },
+ "_urls": [
+ {
+ "params": [],
+ "rels": [],
+ "template": "https://bugzilla.mozilla.org/buglist.cgi?quicksearch={searchTerms}"
+ }
+ ],
+ "_orderHint": null,
+ "_telemetryId": null,
+ "_updateInterval": null,
+ "_updateURL": null,
+ "_iconUpdateURL": null,
+ "_extensionID": null,
+ "_locale": null,
+ "_definedAliases": []
+ },
+ {
+ "_name": "User",
+ "_loadPath": "[other]addEngineWithDetails:set-via-user",
+ "_metaData": {
+ "order": 9,
+ "alias": "UserAlias"
+ },
+ "_urls": [
+ {
+ "params": [],
+ "rels": [],
+ "template": "https://example.com/test?q={searchTerms}"
+ }
+ ],
+ "_telemetryId": null,
+ "_updateInterval": null,
+ "_updateURL": null,
+ "_iconUpdateURL": null,
+ "_extensionID": null,
+ "_locale": null,
+ "_hasPreferredIcon": null
+ },
+ { "_name": "Amazon.com", "_isAppProvided": true, "_metaData": {} }
+ ],
+ "metaData": {
+ "useSavedOrder": true,
+ "locale": "en-US",
+ "region": "GB",
+ "channel": "default",
+ "experiment": "",
+ "distroID": "",
+ "appDefaultEngine": "Google",
+ "current": "Bing",
+ "hash": "5Of6s1D+BDjPRti1wqtFyBTH1PnOf9n6cRwWlEXZhd0="
+ }
+}
diff --git a/toolkit/components/search/tests/xpcshell/data/search-legacy-old-loadPaths.json b/toolkit/components/search/tests/xpcshell/data/search-legacy-old-loadPaths.json
new file mode 100644
index 0000000000..f716fb3e4a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-legacy-old-loadPaths.json
@@ -0,0 +1,135 @@
+{
+ "version": 7,
+ "engines": [
+ {
+ "id": "google@search.mozilla.orgdefault",
+ "_name": "Google",
+ "_isAppProvided": true,
+ "_metaData": { "order": 1 }
+ },
+ {
+ "id": "amazon@search.mozilla.orgen-GB",
+ "_name": "Amazon.co.uk",
+ "_isAppProvided": true,
+ "_metaData": { "order": 2 }
+ },
+ {
+ "id": "bing@search.mozilla.orgdefault",
+ "_name": "Bing",
+ "_isAppProvided": true,
+ "_metaData": { "order": 3 }
+ },
+ {
+ "id": "ddg@search.mozilla.orgdefault",
+ "_name": "DuckDuckGo",
+ "_isAppProvided": true,
+ "_metaData": { "order": 4 }
+ },
+ {
+ "id": "ebay@search.mozilla.orguk",
+ "_name": "eBay",
+ "_isAppProvided": true,
+ "_metaData": { "order": 5 }
+ },
+ {
+ "id": "wikipedia@search.mozilla.orgdefault",
+ "_name": "Wikipedia (en)",
+ "_isAppProvided": true,
+ "_metaData": { "order": 6 }
+ },
+ {
+ "id": "policy-Policy",
+ "_name": "Policy",
+ "_loadPath": "[other]addEngineWithDetails:set-via-policy",
+ "_metaData": { "alias": "PolicyAlias", "order": 6 }
+ },
+ {
+ "id": "bbc163e7-7b1a-47aa-a32c-c59062de2753",
+ "_name": "Bugzilla@Mozilla",
+ "_loadPath": "[https]bugzilla.mozilla.org/bugzillamozilla.xml",
+ "description": "Bugzilla@Mozilla Quick Search",
+ "_metaData": {
+ "loadPathHash": "Bxz6jVe3IIBxLLaafUus536LMyLKoGZm7xsBv/yiTw8=",
+ "order": 8,
+ "alias": "bugzillaAlias"
+ },
+ "_urls": [
+ {
+ "params": [],
+ "rels": [],
+ "template": "https://bugzilla.mozilla.org/buglist.cgi?quicksearch={searchTerms}"
+ }
+ ],
+ "_orderHint": null,
+ "_telemetryId": null,
+ "_updateInterval": null,
+ "_updateURL": null,
+ "_iconUpdateURL": null,
+ "_extensionID": null,
+ "_locale": null,
+ "_definedAliases": []
+ },
+ {
+ "id": "bbc163e7-7b1a-47aa-a32c-c59062de2754",
+ "_name": "User",
+ "_loadPath": "[other]addEngineWithDetails:set-via-user",
+ "_metaData": {
+ "order": 9,
+ "alias": "UserAlias"
+ },
+ "_urls": [
+ {
+ "params": [],
+ "rels": [],
+ "template": "https://example.com/test?q={searchTerms}"
+ }
+ ],
+ "_telemetryId": null,
+ "_updateInterval": null,
+ "_updateURL": null,
+ "_iconUpdateURL": null,
+ "_extensionID": null,
+ "_locale": null,
+ "_hasPreferredIcon": null
+ },
+ {
+ "id": "example@tests.mozilla.orgdefault",
+ "_name": "Example",
+ "_loadPath": "[other]addEngineWithDetails:example@tests.mozilla.org",
+ "description": null,
+ "_iconURL": "",
+ "_metaData": {},
+ "_urls": [
+ {
+ "params": [
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ],
+ "rels": [],
+ "template": "https://example.com/"
+ }
+ ],
+ "_telemetryId": null,
+ "_updateInterval": null,
+ "_updateURL": null,
+ "_iconUpdateURL": null,
+ "_extensionID": "example@tests.mozilla.org",
+ "_locale": "default",
+ "_definedAliases": [],
+ "_hasPreferredIcon": null
+ }
+ ],
+ "metaData": {
+ "useSavedOrder": true,
+ "locale": "en-US",
+ "region": "GB",
+ "channel": "default",
+ "experiment": "",
+ "distroID": "",
+ "appDefaultEngine": "Google",
+ "current": "Bing",
+ "hash": "5Of6s1D+BDjPRti1wqtFyBTH1PnOf9n6cRwWlEXZhd0="
+ }
+}
diff --git a/toolkit/components/search/tests/xpcshell/data/search-legacy-wrong-default-engine-hashes.json b/toolkit/components/search/tests/xpcshell/data/search-legacy-wrong-default-engine-hashes.json
new file mode 100644
index 0000000000..ca7081f565
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-legacy-wrong-default-engine-hashes.json
@@ -0,0 +1,114 @@
+{
+ "version": 1,
+ "buildID": "20121106",
+ "locale": "en-US",
+ "metaData": {
+ "current": "engine2",
+ "private": "engine2",
+ "hash": "wrong-hash-o/HzjHlVpb97AFGH3pY1GZ6CoTQkQslUKRd38/qasto=",
+ "privateHash": "wrong-hash-o/HzjHlVpb97AFGH3pY1GZ6CoTQkQslUKRd38/qasto="
+ },
+ "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": false
+ },
+ "_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-legacy-wrong-third-party-engine-hashes.json b/toolkit/components/search/tests/xpcshell/data/search-legacy-wrong-third-party-engine-hashes.json
new file mode 100644
index 0000000000..25329e083f
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-legacy-wrong-third-party-engine-hashes.json
@@ -0,0 +1,114 @@
+{
+ "version": 1,
+ "buildID": "20121106",
+ "locale": "en-US",
+ "metaData": {
+ "current": "Test search engine",
+ "private": "Test search engine",
+ "hash": "wrong-hash-o/HzjHlVpb97AFGH3pY1GZ6CoTQkQslUKRd38/qasto=",
+ "privateHash": "wrong-hash-o/HzjHlVpb97AFGH3pY1GZ6CoTQkQslUKRd38/qasto="
+ },
+ "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": false
+ },
+ "_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-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-migration.json b/toolkit/components/search/tests/xpcshell/data/search-migration.json
new file mode 100644
index 0000000000..520149a370
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-migration.json
@@ -0,0 +1,68 @@
+{
+ "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": "simple",
+ "_loadPath": "jar:[profile]/extensions/simple@tests.mozilla.org.xpi!/simple.xml",
+ "_shortName": "simple",
+ "description": "A migration test engine",
+ "__searchForm": "http://www.example.com/",
+ "_metaData": {},
+ "_urls": [
+ {
+ "template": "http://www.example.com/search",
+ "rels": [],
+ "resultDomain": "google.com",
+ "params": [
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ]
+ }
+ ],
+ "queryCharset": "UTF-8"
+ },
+ {
+ "_name": "simple search",
+ "_loadPath": "[other]addEngineWithDetails:simple@tests.mozilla.org",
+ "_shortName": "simple search",
+ "description": "A migration test engine",
+ "__searchForm": "http://www.example.com/",
+ "_metaData": {},
+ "_urls": [
+ {
+ "template": "http://www.example.com/search",
+ "rels": [],
+ "resultDomain": "google.com",
+ "params": [
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ]
+ }
+ ],
+ "queryCharset": "UTF-8",
+ "_extensionID": "simple@tests.mozilla.org"
+ }
+ ]
+}
diff --git a/toolkit/components/search/tests/xpcshell/data/search-obsolete-app.json b/toolkit/components/search/tests/xpcshell/data/search-obsolete-app.json
new file mode 100644
index 0000000000..151359ff73
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-obsolete-app.json
@@ -0,0 +1,41 @@
+{
+ "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": "App",
+ "_shortName": "app",
+ "_loadPath": "jar:[app]/omni.ja!distribution.xml",
+ "description": "App 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-distribution.json b/toolkit/components/search/tests/xpcshell/data/search-obsolete-distribution.json
new file mode 100644
index 0000000000..efc609a5af
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-obsolete-distribution.json
@@ -0,0 +1,41 @@
+{
+ "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..8c45b4d61f
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search-obsolete-langpack.json
@@ -0,0 +1,91 @@
+{
+ "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..79d1368687
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search.json
@@ -0,0 +1,72 @@
+{
+ "version": 1,
+ "buildID": "20121106",
+ "locale": "en-US",
+ "metaData": {},
+ "engines": [
+ {
+ "id": "engine1@search.mozilla.orgdefault",
+ "_name": "engine1",
+ "_metaData": {
+ "alias": "testAlias"
+ },
+ "_isAppProvided": true
+ },
+ {
+ "id": "engine2@search.mozilla.orgdefault",
+ "_name": "engine2",
+ "_metaData": {
+ "alias": null,
+ "hidden": true
+ },
+ "_isAppProvided": true
+ },
+ {
+ "id": "test-addon-id@mozilla.orgdefault",
+ "_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..85f51795b6
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/searchSuggestions.sjs
@@ -0,0 +1,181 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+let { NetUtil } = ChromeUtils.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(q, completions = []) {
+ let jsonString = JSON.stringify([q, 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 The data to send as suggestions.
+ */
+ 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("stü")) {
+ writeSuggestions("st\\u00FC", ["stühle", "stüssy"]);
+ } 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 delayMs milliseconds. 200ms is the default, less
+ // than the timeout but hopefully enough to abort before completion.
+ let match = /^delay([0-9]+)/.exec(q);
+ let delayMs = match ? parseInt(match[1]) : 200;
+ response.processAsync();
+ writeSuggestions(q, [q]);
+ setTimeout(() => response.finish(), delayMs);
+ } 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..35240893ec
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data/search_ignorelist.json
@@ -0,0 +1,51 @@
+{
+ "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..5fa44ea692
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data1/engine1/manifest.json
@@ -0,0 +1,27 @@
+{
+ "name": "engine1",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "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..7ab094198b
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data1/engine2/manifest.json
@@ -0,0 +1,27 @@
+{
+ "name": "engine2",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "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/engines.json b/toolkit/components/search/tests/xpcshell/data1/engines.json
new file mode 100644
index 0000000000..05556ef87c
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data1/engines.json
@@ -0,0 +1,60 @@
+{
+ "data": [
+ {
+ "webExtension": {
+ "id": "engine1@search.mozilla.org"
+ },
+ "orderHint": 10000,
+ "appliesTo": [
+ {
+ "included": { "everywhere": true },
+ "default": "yes-if-no-other",
+ "defaultPrivate": "yes-if-no-other"
+ }
+ ]
+ },
+ {
+ "webExtension": {
+ "id": "engine2@search.mozilla.org"
+ },
+ "orderHint": 7000,
+ "appliesTo": [
+ {
+ "included": { "everywhere": true },
+ "default": "no"
+ },
+ {
+ "included": { "everywhere": true },
+ "default": "yes",
+ "experiment": "exp1"
+ }
+ ]
+ },
+ {
+ "webExtension": {
+ "id": "exp2@search.mozilla.org"
+ },
+ "orderHint": 5000,
+ "appliesTo": [
+ {
+ "included": { "everywhere": true },
+ "defaultPrivate": "yes",
+ "experiment": "exp2"
+ }
+ ]
+ },
+ {
+ "webExtension": {
+ "id": "exp3@search.mozilla.org"
+ },
+ "orderHint": 20000,
+ "appliesTo": [
+ {
+ "included": { "everywhere": true },
+ "default": "yes",
+ "experiment": "exp3"
+ }
+ ]
+ }
+ ]
+}
diff --git a/toolkit/components/search/tests/xpcshell/data1/exp2/manifest.json b/toolkit/components/search/tests/xpcshell/data1/exp2/manifest.json
new file mode 100644
index 0000000000..0cd0e080b9
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data1/exp2/manifest.json
@@ -0,0 +1,27 @@
+{
+ "name": "exp2",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "exp2@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "description": "A small test engine",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "exp2",
+ "search_url": "https://2.example.com/searchexp",
+ "params": [
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ]
+ }
+ }
+}
diff --git a/toolkit/components/search/tests/xpcshell/data1/exp3/manifest.json b/toolkit/components/search/tests/xpcshell/data1/exp3/manifest.json
new file mode 100644
index 0000000000..4e023e0fef
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/data1/exp3/manifest.json
@@ -0,0 +1,27 @@
+{
+ "name": "exp3",
+ "manifest_version": 2,
+ "version": "1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "exp3@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "description": "A small test engine",
+ "icons": {
+ "16": "favicon.ico"
+ },
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "exp3",
+ "search_url": "https://3.example.com/searchexp",
+ "params": [
+ {
+ "name": "q",
+ "value": "{searchTerms}"
+ }
+ ]
+ }
+ }
+}
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..749fd2f4ad
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/head_search.js
@@ -0,0 +1,512 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ RemoteSettingsClient:
+ "resource://services-settings/RemoteSettingsClient.sys.mjs",
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+ SearchService: "resource://gre/modules/SearchService.sys.mjs",
+ SearchSettings: "resource://gre/modules/SearchSettings.sys.mjs",
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+ clearTimeout: "resource://gre/modules/Timer.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+var { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+var { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { ExtensionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/ExtensionXPCShellUtils.sys.mjs"
+);
+
+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);
+
+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 = PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME);
+ return IOUtils.readJSON(path, { decompress: true });
+}
+
+function promiseSaveSettingsData(data) {
+ return IOUtils.writeJSON(
+ PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME),
+ data,
+ { compress: true }
+ );
+}
+
+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) {
+ return JSON.parse(await IOUtils.readUTF8(file.path));
+}
+
+/**
+ * Recursively compare two objects and check that every property of expectedObj has the same value
+ * on actualObj.
+ *
+ * @param {object} expectedObj
+ * The source object that we expect to match
+ * @param {object} actualObj
+ * The object to check against the source
+ * @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;
+}
+
+// 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: ["[addon]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`
+ );
+}
+
+/**
+ * @typedef {object} TelemetryDetails
+ * @property {string} engineId
+ * The telemetry ID for the search engine.
+ * @property {string} [displayName]
+ * The search engine's display name.
+ * @property {string} [loadPath]
+ * The load path for the search engine.
+ * @property {string} [submissionUrl]
+ * The submission URL for the search engine.
+ * @property {string} [verified]
+ * Whether the search engine is verified.
+ */
+
+/**
+ * Asserts that default search engine telemetry has been correctly reported
+ * to Glean.
+ *
+ * @param {object} expected
+ * An object containing telemetry details for normal and private engines.
+ * @param {TelemetryDetails} expected.normal
+ * An object with the expected details for the normal search engine.
+ * @param {TelemetryDetails} [expected.private]
+ * An object with the expected details for the private search engine.
+ */
+async function assertGleanDefaultEngine(expected) {
+ await TestUtils.waitForCondition(
+ () =>
+ Glean.searchEngineDefault.engineId.testGetValue() ==
+ (expected.normal.engineId ?? ""),
+ "Should have set the correct telemetry id for the normal engine"
+ );
+
+ await TestUtils.waitForCondition(
+ () =>
+ Glean.searchEnginePrivate.engineId.testGetValue() ==
+ (expected.private?.engineId ?? ""),
+ "Should have set the correct telemetry id for the private engine"
+ );
+
+ for (let property of [
+ "displayName",
+ "loadPath",
+ "submissionUrl",
+ "verified",
+ ]) {
+ if (property in expected.normal) {
+ Assert.equal(
+ Glean.searchEngineDefault[property].testGetValue(),
+ expected.normal[property] ?? "",
+ `Should have set ${property} correctly`
+ );
+ }
+ if (expected.private && property in expected.private) {
+ Assert.equal(
+ Glean.searchEnginePrivate[property].testGetValue(),
+ expected.private[property] ?? "",
+ `Should have set ${property} correctly`
+ );
+ }
+ }
+}
+
+/**
+ * A simple observer to ensure we get only the expected notifications.
+ */
+class SearchObserver {
+ constructor(expectedNotifications, returnEngineForNotification = false) {
+ this.observer = this.observer.bind(this);
+ this.deferred = PromiseUtils.defer();
+ this.expectedNotifications = expectedNotifications;
+ this.returnEngineForNotification = returnEngineForNotification;
+
+ Services.obs.addObserver(this.observer, SearchUtils.TOPIC_ENGINE_MODIFIED);
+
+ this.timeout = setTimeout(this.handleTimeout.bind(this), 1000);
+ }
+
+ get promise() {
+ return this.deferred.promise;
+ }
+
+ handleTimeout() {
+ this.deferred.reject(
+ new Error(
+ "Waiting for Notifications timed out, only received: " +
+ this.expectedNotifications.join(",")
+ )
+ );
+ }
+
+ 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.returnEngineForNotification &&
+ data == this.returnEngineForNotification
+ ) {
+ this.engineToReturn = subject.QueryInterface(Ci.nsISearchEngine);
+ }
+
+ this.expectedNotifications.shift();
+
+ if (!this.expectedNotifications.length) {
+ clearTimeout(this.timeout);
+ delete this.timeout;
+ this.deferred.resolve(this.engineToReturn);
+ Services.obs.removeObserver(
+ this.observer,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+ }
+ }
+}
+
+/**
+ * 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 = [
+ // Harness issues.
+ 'property "localProfileDir" is non-configurable and can\'t be deleted',
+ 'property "profileDir" is non-configurable and can\'t be deleted',
+ // These can be emitted by `resource://services-settings/Utils.jsm` when
+ // remote settings is fetched (e.g. via IgnoreLists).
+ "NetworkError: Network request failed",
+ // Also remote settings, see bug 1812040.
+ "Unexpected content-type",
+];
+
+let endConsoleListening = TestUtils.listenForConsoleMessages();
+
+registerCleanupFunction(async () => {
+ let msgs = await endConsoleListening();
+ for (let msg of msgs) {
+ msg = msg.wrappedJSObject;
+ if (msg.level != "error") {
+ continue;
+ }
+
+ if (!msg.arguments?.length) {
+ Assert.ok(
+ false,
+ "Unexpected console message received during test: " + msg
+ );
+ } else {
+ let firstArg = msg.arguments[0];
+ // Use the appropriate message depending on the object supplied to
+ // the first argument.
+ let message = firstArg.messageContents ?? firstArg.message ?? firstArg;
+ if (!consoleAllowList.some(e => message.includes(e))) {
+ Assert.ok(
+ false,
+ "Unexpected console message received during test: " + message
+ );
+ }
+ }
+ }
+});
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..4c0a009f37
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/method-extensions/engines.json
@@ -0,0 +1,45 @@
+{
+ "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..a85cdaaa0f
--- /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",
+ "browser_specific_settings": {
+ "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..dce9bfb512
--- /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",
+ "browser_specific_settings": {
+ "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/chromeicon.xml b/toolkit/components/search/tests/xpcshell/opensearch/chromeicon.xml
new file mode 100644
index 0000000000..856732c6d6
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/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/opensearch/insecure-and-insecurely-updated1.xml b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-insecurely-updated1.xml
new file mode 100644
index 0000000000..3131c25f37
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-insecurely-updated1.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>ii1</ShortName>
+<Description>Insecure and insecurely updated 1</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="http://example.com/ii1">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="http://example.com/ii1.xml" />
+<SearchForm>http://example.com/ii1</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-insecurely-updated2.xml b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-insecurely-updated2.xml
new file mode 100644
index 0000000000..a3c850d4d9
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-insecurely-updated2.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>ii2</ShortName>
+<Description>Insecure and insecurely updated 2</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="http://example.com/ii2">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="http://example.com/ii2.xml" />
+<SearchForm>http://example.com/ii2</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-no-update-url1.xml b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-no-update-url1.xml
new file mode 100644
index 0000000000..75a5da8e7f
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-no-update-url1.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>inu1</ShortName>
+<Description>Insecure and no update URL 1</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="http://example.com/inu1">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<SearchForm>http://example.com/inu1</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-securely-updated1.xml b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-securely-updated1.xml
new file mode 100644
index 0000000000..9427747722
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/insecure-and-securely-updated1.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>is1</ShortName>
+<Description>Insecure and securely updated 1</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="http://example.com/is1">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="https://example.com/is1.xml" />
+<SearchForm>http://example.com/is1</SearchForm>
+</OpenSearchDescription>
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/resourceicon.xml b/toolkit/components/search/tests/xpcshell/opensearch/resourceicon.xml
new file mode 100644
index 0000000000..32861c34ea
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/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/opensearch/secure-and-insecurely-updated1.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-insecurely-updated1.xml
new file mode 100644
index 0000000000..d8a62d0e18
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-insecurely-updated1.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>si1</ShortName>
+<Description>Secure and insecurely updated 1</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/si1">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="http://example.com/si1.xml" />
+<SearchForm>https://example.com/si1</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-and-insecurely-updated2.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-insecurely-updated2.xml
new file mode 100644
index 0000000000..f707e5eb3d
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-insecurely-updated2.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>si2</ShortName>
+<Description>Secure and insecurely updated 2</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/si2">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="http://example.com/si2.xml" />
+<SearchForm>https://example.com/si2</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-and-no-update-url1.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-no-update-url1.xml
new file mode 100644
index 0000000000..6dcbbb126c
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-no-update-url1.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>snu1</ShortName>
+<Description>Secure and no update URL 1</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/snu1">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<SearchForm>https://example.com/snu1</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated-insecure-form.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated-insecure-form.xml
new file mode 100644
index 0000000000..15a0b6a517
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated-insecure-form.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>ssif</ShortName>
+<Description>Secure and securely updated insecure form</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/ssif">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="https://example.com/ssif.xml" />
+<SearchForm>http://example.com/ssif</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated1.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated1.xml
new file mode 100644
index 0000000000..593c8bec8c
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated1.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>ss1</ShortName>
+<Description>Secure and securely updated 1</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/ss1">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="https://example.com/ss1.xml" />
+<SearchForm>https://example.com/ss1</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated2.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated2.xml
new file mode 100644
index 0000000000..30a20b754a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated2.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>ss2</ShortName>
+<Description>Secure and securely updated 2</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/ss2">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="https://example.com/ss2.xml" />
+<SearchForm>https://example.com/ss2</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated3.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated3.xml
new file mode 100644
index 0000000000..8b86a82199
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-and-securely-updated3.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>ss3</ShortName>
+<Description>Secure and securely updated 3</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/ss3">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<Url type="application/opensearchdescription+xml"
+ rel="self"
+ template="https://example.com/ss3.xml" />
+<SearchForm>https://example.com/ss3</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-localhost.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-localhost.xml
new file mode 100644
index 0000000000..89d96f2c43
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-localhost.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>sl</ShortName>
+<Description>Secure localhost</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="http://localhost:8080">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<SearchForm>http://localhost:8080/sl</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-onionv2.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-onionv2.xml
new file mode 100644
index 0000000000..8da3995a71
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-onionv2.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>sov2</ShortName>
+<Description>Secure onion v2</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="http://s3zkf3ortukqklec.onion">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<SearchForm>http://s3zkf3ortukqklec.onion/sov2</SearchForm>
+</OpenSearchDescription>
diff --git a/toolkit/components/search/tests/xpcshell/opensearch/secure-onionv3.xml b/toolkit/components/search/tests/xpcshell/opensearch/secure-onionv3.xml
new file mode 100644
index 0000000000..c8256ca28a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/opensearch/secure-onionv3.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.0/">
+<ShortName>sov3</ShortName>
+<Description>Secure onion v3</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="http://ydemw5wg5cseltau22u4fjfrmfshopaldpoznsirb3rgo2gv6uh4s2y5.onion">
+ <Param name="q" value="{searchTerms}"/>
+</Url>
+<SearchForm>http://ydemw5wg5cseltau22u4fjfrmfshopaldpoznsirb3rgo2gv6uh4s2y5.onion/sov3</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..87ade57a51
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/head_searchconfig.js
@@ -0,0 +1,614 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchEngine: "resource://gre/modules/SearchEngine.sys.mjs",
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
+});
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
+
+const GLOBAL_SCOPE = this;
+const TEST_DEBUG = Services.env.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 = Services.env.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.
+ *
+ * @param {string} [version]
+ * The version to simulate for running the tests.
+ */
+ async setup(version = "42.0") {
+ AddonTestUtils.init(GLOBAL_SCOPE);
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ version,
+ version
+ );
+
+ 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: SearchUtils.MODIFIED_APP_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 IOUtils.readUTF8(do_get_file("all-locales").path);
+ // "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
+ * The config locales config, containing the locals to match against.
+ * @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.
+ * @param {boolean} exactMatch
+ * Whether to use an exactMatch for the identifier.
+ * @returns {Engine}
+ * Returns the engine if found, null otherwise.
+ */
+ _findEngine(engines, identifier, exactMatch) {
+ return engines.find(engine =>
+ exactMatch
+ ? engine.identifier == identifier
+ : 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,
+ this._config.identifierExactMatch ?? false
+ );
+
+ // 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 excluded =
+ hasExcluded &&
+ this._localeRegionInSection(config.excluded, region, locale);
+ if (
+ (included && (!hasExcluded || !excluded)) ||
+ (!hasIncluded && hasExcluded && !excluded)
+ ) {
+ 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.appDefaultEngine],
+ 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.appPrivateDefaultEngine],
+ 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._config.identifierExactMatch ?? false
+ );
+ 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.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}"`
+ );
+ let uri = engine.searchForm;
+ this.assertOk(
+ !uri.includes(rule.searchUrlCode),
+ `"${rule.searchUrlCode}" should not be in the search form URL.`
+ );
+ }
+ if (rule.searchUrlCodeNotInQuery) {
+ const submission = engine.getSubmission("test", URLTYPE_SEARCH_HTML);
+ this.assertOk(
+ submission.uri.includes(rule.searchUrlCodeNotInQuery),
+ `Expected "${rule.searchUrlCodeNotInQuery}" in search url "${submission.uri.spec}"`
+ );
+ }
+ 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..30d4d478f0
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_amazon.js
@@ -0,0 +1,361 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const mainShippedRegions = [
+ "at",
+ "au",
+ "be",
+ "ca",
+ "ch",
+ "cn",
+ "de",
+ "es",
+ "fr",
+ "mc",
+ "gb",
+ "ie",
+ "it",
+ "jp",
+ "nl",
+ "pt",
+ "se",
+ "sm",
+ "us",
+ "va",
+];
+
+const amazondotcomLocales = [
+ "ach",
+ "af",
+ "ar",
+ "az",
+ "bg",
+ "cak",
+ "cy",
+ "da",
+ "el",
+ "en-US",
+ "en-GB",
+ "eo",
+ "es-AR",
+ "eu",
+ "fa",
+ "ga-IE",
+ "gd",
+ "gl",
+ "gn",
+ "hr",
+ "hy-AM",
+ "ia",
+ "is",
+ "ka",
+ "km",
+ "lt",
+ "mk",
+ "ms",
+ "my",
+ "nb-NO",
+ "nn-NO",
+ "pt-PT",
+ "ro",
+ "si",
+ "sq",
+ "sr",
+ "th",
+ "tl",
+ "trs",
+ "uz",
+];
+
+const test = new SearchConfigTest({
+ identifier: "amazon",
+ default: {
+ // Not default anywhere.
+ },
+ available: {
+ included: [
+ {
+ // The main regions we ship Amazon to. Below this are special cases.
+ regions: mainShippedRegions,
+ },
+ {
+ // Amazon.com ships to all of these locales, excluding the ones where
+ // we ship other items, but it does not matter that they are duplicated
+ // in the available list.
+ locales: {
+ matches: amazondotcomLocales,
+ },
+ },
+ {
+ // Amazon.in
+ regions: ["in"],
+ locales: {
+ matches: ["bn", "gu-IN", "kn", "mr", "pa-IN", "ta", "te", "ur"],
+ },
+ },
+ ],
+ excluded: [
+ {
+ // Extra special case for cn as that only ships to the one locale.
+ regions: ["in"],
+ locales: {
+ matches: amazondotcomLocales,
+ },
+ },
+ ],
+ },
+ details: [
+ {
+ domain: "amazon.com.au",
+ telemetryId: "amazon-au",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["au"],
+ },
+ ],
+ suggestionUrlBase: "https://completion.amazon.com.au/search/complete",
+ suggestUrlCode: "mkt=111172",
+ },
+ {
+ domain: "amazon.ca",
+ telemetryId: "amazon-ca",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["ca"],
+ },
+ ],
+ searchUrlCode: "tag=mozillacanada-20",
+ suggestionUrlBase: "https://completion.amazon.ca/search/complete",
+ suggestUrlCode: "mkt=7",
+ },
+ {
+ domain: "amazon.cn",
+ telemetryId: "amazondotcn",
+ included: [
+ {
+ regions: ["cn"],
+ },
+ ],
+ searchUrlCode: "ix=sunray",
+ noSuggestionsURL: true,
+ },
+ {
+ domain: "amazon.co.jp",
+ telemetryId: "amazon-jp",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["jp"],
+ },
+ ],
+ searchUrlCode: "tag=mozillajapan-fx-22",
+ suggestionUrlBase: "https://completion.amazon.co.jp/search/complete",
+ suggestUrlCode: "mkt=6",
+ },
+ {
+ domain: "amazon.co.uk",
+ telemetryId: "amazon-en-GB",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["gb", "ie"],
+ },
+ ],
+ searchUrlCode: "tag=firefox-uk-21",
+ suggestionUrlBase: "https://completion.amazon.co.uk/search/complete",
+ suggestUrlCode: "mkt=3",
+ },
+ {
+ domain: "amazon.com",
+ telemetryId: "amazondotcom-us",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["us"],
+ },
+ ],
+ searchUrlCode: "tag=moz-us-20",
+ },
+ {
+ domain: "amazon.com",
+ telemetryId: "amazondotcom",
+ aliases: ["@amazon"],
+ included: [
+ {
+ locales: {
+ matches: amazondotcomLocales,
+ },
+ },
+ ],
+ excluded: [{ regions: mainShippedRegions }],
+ searchUrlCode: "tag=mozilla-20",
+ },
+ {
+ domain: "amazon.de",
+ telemetryId: "amazon-de",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["at", "ch", "de"],
+ },
+ ],
+ searchUrlCode: "tag=firefox-de-21",
+ suggestionUrlBase: "https://completion.amazon.de/search/complete",
+ suggestUrlCode: "mkt=4",
+ },
+ {
+ domain: "amazon.es",
+ telemetryId: "amazon-es",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["es", "pt"],
+ },
+ ],
+ searchUrlCode: "tag=mozillaspain-21",
+ suggestionUrlBase: "https://completion.amazon.es/search/complete",
+ suggestUrlCode: "mkt=44551",
+ },
+ {
+ domain: "amazon.fr",
+ telemetryId: "amazon-france",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["fr", "mc"],
+ },
+ {
+ regions: ["be"],
+ locales: {
+ matches: ["fr"],
+ },
+ },
+ ],
+ searchUrlCode: "tag=firefox-fr-21",
+ suggestionUrlBase: "https://completion.amazon.fr/search/complete",
+ suggestUrlCode: "mkt=5",
+ },
+ {
+ domain: "amazon.in",
+ telemetryId: "amazon-in",
+ aliases: ["@amazon"],
+ included: [
+ {
+ locales: {
+ matches: ["bn", "gu-IN", "kn", "mr", "pa-IN", "ta", "te", "ur"],
+ },
+ regions: ["in"],
+ },
+ ],
+ suggestionUrlBase: "https://completion.amazon.in/search/complete",
+ suggestUrlCode: "mkt=44571",
+ },
+ {
+ domain: "amazon.it",
+ telemetryId: "amazon-it",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["it", "sm", "va"],
+ },
+ ],
+ searchUrlCode: "tag=firefoxit-21",
+ suggestionUrlBase: "https://completion.amazon.it/search/complete",
+ suggestUrlCode: "mkt=35691",
+ },
+ {
+ domain: "amazon.nl",
+ telemetryId: "amazon-nl",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["nl"],
+ },
+ ],
+ searchUrlCode: "tag=mozillanether-21",
+ suggestionUrlBase: "https://completion.amazon.nl/search/complete",
+ suggestUrlCode: "mkt=328451",
+ },
+ {
+ domain: "amazon.nl",
+ telemetryId: "amazon-nl",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["be"],
+ },
+ ],
+ excluded: [
+ {
+ locales: {
+ matches: ["fr"],
+ },
+ },
+ ],
+ searchUrlCode: "tag=mozillanether-21",
+ suggestionUrlBase: "https://completion.amazon.nl/search/complete",
+ suggestUrlCode: "mkt=328451",
+ },
+ {
+ domain: "amazon.se",
+ telemetryId: "amazon-se",
+ aliases: ["@amazon"],
+ included: [
+ {
+ regions: ["se"],
+ },
+ ],
+ searchUrlCode: "tag=mozillasweede-21",
+ suggestionUrlBase: "https://completion.amazon.se/search/complete",
+ suggestUrlCode: "mkt=704403121",
+ },
+ ],
+});
+
+add_task(async function setup() {
+ // We only need to do setup on one of the tests.
+ await test.setup("89.0");
+});
+
+add_task(async function test_searchConfig_amazon() {
+ await test.run();
+});
+
+add_task(async function test_searchConfig_amazon_pre89() {
+ AddonTestUtils.createAppInfo(
+ "xpcshell@tests.mozilla.org",
+ "XPCShell",
+ "88.0",
+ "88.0"
+ );
+ // For pre-89, Amazon has a slightly different config.
+ let details = test._config.details.find(
+ d => d.telemetryId == "amazondotcom-us"
+ );
+ details.telemetryId = "amazondotcom";
+ details.searchUrlCode = "tag=mozilla-20";
+
+ // nl not present due to urls that don't work.
+ let availableIn = test._config.available.included;
+ availableIn[0].regions = availableIn[0].regions.filter(
+ r => r != "be" && r != "nl"
+ );
+ availableIn.push({
+ regions: ["be"],
+ locales: {
+ matches: ["fr"],
+ },
+ });
+ // Due to the way the exclusions work, no Amazon present in nl/be in the
+ // dot com locales for pre-89.
+ test._config.available.excluded[0].regions.push("be", "nl");
+ test._config.details = test._config.details.filter(
+ d => d.telemetryId != "amazon-nl"
+ );
+
+ 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..01094b260a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_baidu.js
@@ -0,0 +1,43 @@
+/* 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",
+ },
+ ],
+});
+
+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..5e91c9f49b
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_bing.js
@@ -0,0 +1,133 @@
+/* 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",
+ "bs",
+ "ca",
+ "ca-valencia",
+ "cak",
+ "cs",
+ "cy",
+ "da",
+ "de",
+ "dsb",
+ "el",
+ "eo",
+ "es-CL",
+ "es-ES",
+ "es-MX",
+ "eu",
+ "fa",
+ "ff",
+ "fi",
+ "fr",
+ "fur",
+ "fy-NL",
+ "gd",
+ "gl",
+ "gn",
+ "gu-IN",
+ "he",
+ "hi-IN",
+ "hr",
+ "hsb",
+ "hy-AM",
+ "ia",
+ "id",
+ "is",
+ "it",
+ "ja-JP-macos",
+ "ja",
+ "ka",
+ "kab",
+ "km",
+ "kn",
+ "lij",
+ "lo",
+ "lt",
+ "meh",
+ "mk",
+ "ms",
+ "my",
+ "nb-NO",
+ "ne-NP",
+ "nl",
+ "nn-NO",
+ "oc",
+ "pa-IN",
+ "pt-BR",
+ "rm",
+ "ro",
+ "sc",
+ "sco",
+ "son",
+ "sq",
+ "sr",
+ "sv-SE",
+ "te",
+ "th",
+ "tl",
+ "tr",
+ "trs",
+ "uk",
+ "ur",
+ "uz",
+ "wo",
+ "xh",
+ "zh-CN",
+ ],
+ startsWith: ["bn", "en"],
+ },
+ },
+ ],
+ },
+ details: [
+ {
+ included: [{}],
+ domain: "bing.com",
+ telemetryId:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "bing-esr" : "bing",
+ codes: {
+ searchbar: "form=MOZSBR",
+ keyword: "form=MOZLBR",
+ contextmenu: "form=MOZCON",
+ homepage: "form=MOZSPG",
+ newtab: "form=MOZTSB",
+ },
+ searchUrlCode:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "pc=MOZR" : "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..0b44a5509e
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_distributions.js
@@ -0,0 +1,346 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+ SearchService: "resource://gre/modules/SearchService.sys.mjs",
+});
+
+const tests = [];
+
+for (let canonicalId of ["canonical", "canonical-001"]) {
+ 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({
+ locale: "en-US",
+ region: "US",
+ distribution: "canonical-002",
+ test: engines =>
+ hasParams(engines, "Google", "searchbar", "client=ubuntu-sn") &&
+ hasParams(engines, "Google", "searchbar", "channel=fs") &&
+ hasTelemetryId(engines, "Google", "google-ubuntu-sn"),
+});
+
+tests.push({
+ locale: "en-US",
+ region: "GB",
+ distribution: "canonical-002",
+ test: engines =>
+ hasParams(engines, "Google", "searchbar", "client=ubuntu-sn") &&
+ hasParams(engines, "Google", "searchbar", "channel=fs") &&
+ hasTelemetryId(engines, "Google", "google-ubuntu-sn"),
+});
+
+tests.push({
+ locale: "zh-CN",
+ region: "CN",
+ distribution: "MozillaOnline",
+ test: engines =>
+ hasParams(engines, "亚马逊", "searchbar", "ie=UTF8") &&
+ hasParams(engines, "亚马逊", "suggestions", "tag=mozilla") &&
+ hasParams(engines, "亚马逊", "homepage", "camp=536") &&
+ hasParams(engines, "亚马逊", "homepage", "creative=3200") &&
+ hasParams(engines, "亚马逊", "homepage", "index=aps") &&
+ hasParams(engines, "亚马逊", "homepage", "linkCode=ur2") &&
+ 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"),
+});
+
+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: "en-US",
+ region: "US",
+ distribution: "mint-001",
+ test: engines =>
+ hasParams(engines, "DuckDuckGo", "searchbar", "t=lm") &&
+ hasParams(engines, "Google", "searchbar", "client=firefox-b-1-lm") &&
+ hasDefault(engines, "Google") &&
+ hasEnginesFirst(engines, ["Google"]) &&
+ hasTelemetryId(engines, "Google", "google-b-1-lm"),
+});
+
+tests.push({
+ locale: "en-GB",
+ region: "GB",
+ distribution: "mint-001",
+ test: engines =>
+ hasParams(engines, "DuckDuckGo", "searchbar", "t=lm") &&
+ hasParams(engines, "Google", "searchbar", "client=firefox-b-lm") &&
+ hasDefault(engines, "Google") &&
+ hasEnginesFirst(engines, ["Google"]) &&
+ hasTelemetryId(engines, "Google", "google-b-lm"),
+});
+
+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..379ef9d217
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_duckduckgo.js
@@ -0,0 +1,35 @@
+/* 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:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "ddg-esr" : "ddg",
+ searchUrlCode:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "t=ftsa" : "t=ffab",
+ },
+ ],
+});
+
+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..df89072425
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_ebay.js
@@ -0,0 +1,290 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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": ["fur", "it", "lij", "sc"],
+ "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",
+ "fur",
+ "fr",
+ "fy-NL",
+ "ga-IE",
+ "gd",
+ "gl",
+ "hsb",
+ "it",
+ "lij",
+ "nl",
+ "rm",
+ "sc",
+ "wo",
+ ],
+ },
+ },
+ {
+ regions: ["au", "be", "ca", "ch", "gb", "ie", "nl", "us"],
+ locales: {
+ matches: ["en-US"],
+ },
+ },
+ {
+ regions: ["gb"],
+ locales: {
+ matches: ["sco"],
+ },
+ },
+ ],
+ },
+ 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: "www.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: "www.ebay.at",
+ telemetryId: "ebay-at",
+ included: [
+ {
+ regions: ["at"],
+ locales: { matches: ["de", "dsb", "hsb"] },
+ },
+ ],
+ searchUrlCode: "mkrid=5221-53469-19255-0",
+ suggestUrlCode: "sId=16",
+ },
+ {
+ domain: "www.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: "www.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: "www.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: "www.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: "www.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: "www.ebay.co.uk",
+ telemetryId: "ebay-uk",
+ included: [
+ {
+ locales: { matches: DOMAIN_LOCALES["ebay-uk"] },
+ },
+ {
+ locales: { matches: ["unknown", "en-US", "sco"] },
+ regions: ["gb"],
+ },
+ ],
+ excluded: [{ regions: ["au", "ie"] }],
+ searchUrlCode: "mkrid=710-53481-19255-0",
+ suggestUrlCode: "sId=3",
+ },
+ {
+ domain: "www.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: "www.ebay.es",
+ telemetryId: "ebay-es",
+ included: [
+ {
+ locales: {
+ matches: DOMAIN_LOCALES["ebay-es"],
+ },
+ },
+ ],
+ searchUrlCode: "mkrid=1185-53479-19255-0",
+ suggestUrlCode: "sId=186",
+ },
+ {
+ domain: "www.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: "www.ebay.it",
+ telemetryId: "ebay-it",
+ included: [
+ {
+ locales: { matches: DOMAIN_LOCALES["ebay-it"] },
+ },
+ ],
+ searchUrlCode: "mkrid=724-53478-19255-0",
+ suggestUrlCode: "sId=101",
+ },
+ {
+ domain: "www.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_ecosia.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_ecosia.js
new file mode 100644
index 0000000000..61d2fd9abc
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_ecosia.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const test = new SearchConfigTest({
+ identifier: "ecosia",
+ aliases: [],
+ default: {
+ // Not default anywhere.
+ },
+ available: {
+ included: [
+ {
+ locales: {
+ matches: ["de"],
+ },
+ },
+ ],
+ },
+ details: [
+ {
+ included: [{}],
+ domain: "www.ecosia.org",
+ telemetryId: "ecosia",
+ searchUrlCode: "tt=mzl",
+ },
+ ],
+});
+
+add_task(async function setup() {
+ await test.setup();
+});
+
+add_task(async function test_searchConfig_ecosia() {
+ 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..dc2098b066
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_google.js
@@ -0,0 +1,173 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+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: ["cn"],
+ locales: {
+ matches: ["zh-CN"],
+ },
+ },
+ ],
+ },
+ available: {
+ excluded: [
+ // Should be available everywhere.
+ ],
+ },
+ details: [
+ {
+ included: [{ regions: ["us"] }],
+ domain: "google.com",
+ telemetryId:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr"
+ ? "google-b-1-e"
+ : "google-b-1-d",
+ codes:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr"
+ ? "client=firefox-b-1-e"
+ : "client=firefox-b-1-d",
+ },
+ {
+ excluded: [{ regions: ["us", "by", "kz", "ru", "tr"] }],
+ included: [{}],
+ domain: "google.com",
+ telemetryId:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "google-b-e" : "google-b-d",
+ codes:
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr"
+ ? "client=firefox-b-e"
+ : "client=firefox-b-d",
+ },
+ {
+ included: [{ regions: ["by", "kz", "ru", "tr"] }],
+ domain: "google.com",
+ telemetryId: "google-com-nocodes",
+ },
+ ],
+});
+
+add_task(async function setup() {
+ sinon.spy(NimbusFeatures.search, "onUpdate");
+ sinon.stub(NimbusFeatures.search, "ready").resolves();
+ 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"
+ );
+ }
+
+ // Reset the pref values for next tests
+ for (const testData of TEST_DATA) {
+ defaultBranch.setCharPref("param." + testData.pref, "");
+ }
+});
+
+add_task(async function test_searchConfig_google_with_nimbus() {
+ let sandbox = sinon.createSandbox();
+ // Test a couple of configurations with a MozParam set up.
+ const TEST_DATA = [
+ {
+ locale: "en-US",
+ region: "US",
+ expected: "nimbus_us_param",
+ },
+ {
+ locale: "en-US",
+ region: "GB",
+ expected: "nimbus_row_param",
+ },
+ ];
+
+ Assert.ok(
+ NimbusFeatures.search.onUpdate.called,
+ "Should register an update listener for Nimbus experiments"
+ );
+ // Stub getVariable to populate the cache with our expected data
+ sandbox.stub(NimbusFeatures.search, "getVariable").returns([
+ { key: "google_channel_us", value: "nimbus_us_param" },
+ { key: "google_channel_row", value: "nimbus_row_param" },
+ ]);
+ // Set the pref cache with Nimbus values
+ NimbusFeatures.search.onUpdate.firstCall.args[0]();
+
+ 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(
+ NimbusFeatures.search.ready.called,
+ "Should wait for Nimbus to get ready"
+ );
+ Assert.ok(
+ NimbusFeatures.search.getVariable,
+ "Should call NimbusFeatures.search.getVariable to populate the cache"
+ );
+ Assert.ok(
+ submission.uri.query.split("&").includes("channel=" + testData.expected),
+ "Should be including the correct MozParam parameter for the engine"
+ );
+ }
+
+ sandbox.restore();
+});
diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_mailru.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_mailru.js
new file mode 100644
index 0000000000..4d413f0b5b
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_mailru.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const test = new SearchConfigTest({
+ identifier: "mailru",
+ aliases: [],
+ default: {
+ // Not default anywhere.
+ },
+ available: {
+ included: [
+ {
+ locales: {
+ matches: ["ru"],
+ },
+ },
+ ],
+ },
+ details: [
+ {
+ included: [{}],
+ domain: "go.mail.ru",
+ telemetryId: "mailru",
+ codes: "gp=900200",
+ searchUrlCode: "frc=900200",
+ },
+ ],
+});
+
+add_task(async function setup() {
+ await test.setup();
+});
+
+add_task(async function test_searchConfig_mailru() {
+ await test.run();
+}).skip();
diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_qwant.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_qwant.js
new file mode 100644
index 0000000000..8db31fcc24
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_qwant.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const test = new SearchConfigTest({
+ identifier: "qwant",
+ aliases: ["@qwant"],
+ default: {
+ // Not default anywhere.
+ },
+ available: {
+ included: [
+ {
+ locales: {
+ matches: ["fr"],
+ },
+ },
+ ],
+ },
+ details: [
+ {
+ included: [{}],
+ domain: "www.qwant.com",
+ telemetryId: "qwant",
+ searchUrlCode: "client=brz-moz",
+ suggestUrlCode: "client=opensearch",
+ },
+ ],
+});
+
+add_task(async function setup() {
+ await test.setup();
+});
+
+add_task(async function test_searchConfig_qwant() {
+ await test.run();
+});
diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_rakuten.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_rakuten.js
new file mode 100644
index 0000000000..0577490dc2
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_rakuten.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const test = new SearchConfigTest({
+ identifier: "rakuten",
+ aliases: [],
+ default: {
+ // Not default anywhere.
+ },
+ available: {
+ included: [
+ {
+ locales: {
+ matches: ["ja", "ja-JP-macos"],
+ },
+ },
+ ],
+ },
+ details: [
+ {
+ included: [{}],
+ domain: "rakuten.co.jp",
+ telemetryId: "rakuten",
+ searchUrlCodeNotInQuery: "013ca98b.cd7c5f0c",
+ },
+ ],
+});
+
+add_task(async function setup() {
+ await test.setup();
+});
+
+add_task(async function test_searchConfig_rakuten() {
+ await test.run();
+});
diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_selector_db_out_of_date.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_selector_db_out_of_date.js
new file mode 100644
index 0000000000..742cfaec8a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_selector_db_out_of_date.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ RemoteSettingsWorker:
+ "resource://services-settings/RemoteSettingsWorker.sys.mjs",
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+do_get_profile();
+
+add_task(async function test_selector_db_out_of_date() {
+ let searchConfig = RemoteSettings(SearchUtils.SETTINGS_KEY);
+
+ // Do an initial get to pre-seed the database.
+ await searchConfig.get();
+
+ // Now clear the database and re-fill it.
+ let db = searchConfig.db;
+ await db.clear();
+ let databaseEntries = await db.list();
+ Assert.equal(databaseEntries.length, 0, "Should have cleared the database.");
+
+ // Add a dummy record with an out-of-date last modified.
+ await RemoteSettingsWorker._execute("_test_only_import", [
+ "main",
+ SearchUtils.SETTINGS_KEY,
+ [
+ {
+ id: "b70edfdd-1c3f-4b7b-ab55-38cb048636c0",
+ default: "yes",
+ webExtension: { id: "outofdate@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ last_modified: 1606227264000,
+ },
+ ],
+ 1606227264000,
+ ]);
+
+ // Now load the configuration and check we get what we expect.
+ let engineSelector = new SearchEngineSelector();
+ let result = await engineSelector.fetchEngineConfiguration({
+ // Use the fallback default locale/regions to get a simple list.
+ locale: "default",
+ region: "default",
+ });
+ Assert.deepEqual(
+ result.engines.map(e => e.webExtension.id),
+ [
+ "google@search.mozilla.org",
+ "wikipedia@search.mozilla.org",
+ "ddg@search.mozilla.org",
+ ],
+ "Should have returned the correct data."
+ );
+});
diff --git a/toolkit/components/search/tests/xpcshell/searchconfigs/test_yahoojp.js b/toolkit/components/search/tests/xpcshell/searchconfigs/test_yahoojp.js
new file mode 100644
index 0000000000..25091fc37e
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/test_yahoojp.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const test = new SearchConfigTest({
+ identifier: "yahoo-jp",
+ identifierExactMatch: true,
+ aliases: [],
+ default: {
+ // Not default anywhere.
+ },
+ available: {
+ included: [
+ {
+ locales: {
+ matches: ["ja", "ja-JP-macos"],
+ },
+ },
+ ],
+ },
+ details: [
+ {
+ included: [{}],
+ domain: "search.yahoo.co.jp",
+ telemetryId: "yahoo-jp",
+ searchUrlCode: "fr=mozff",
+ },
+ ],
+});
+
+add_task(async function setup() {
+ await test.setup();
+});
+
+add_task(async function test_searchConfig_yahoojp() {
+ await test.run();
+});
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..c315165e5f
--- /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();
+}).skip();
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..17cbd5350d
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/searchconfigs/xpcshell.ini
@@ -0,0 +1,34 @@
+[DEFAULT]
+firefox-appdir = browser
+head = head_searchconfig.js
+dupe-manifest =
+support-files =
+ ../../../../../../browser/locales/all-locales
+tags=searchconfig remote-settings
+# 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_ecosia.js]
+[test_google.js]
+[test_mailru.js]
+[test_qwant.js]
+[test_rakuten.js]
+[test_selector_db_out_of_date.js]
+[test_yahoojp.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..b62eb9bb2b
--- /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",
+ "browser_specific_settings": {
+ "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..f4ce227e9f
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/simple-engines/engines.json
@@ -0,0 +1,27 @@
+{
+ "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..203590f44d
--- /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",
+ "browser_specific_settings": {
+ "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..67d2974753
--- /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",
+ "browser_specific_settings": {
+ "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..fcb5e03e82
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test-extensions/engines.json
@@ -0,0 +1,58 @@
+{
+ "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
new file mode 100644
index 0000000000..4314071e24
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-af.ico
Binary files differ
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
new file mode 100644
index 0000000000..dda80dfd88
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test-extensions/multilocale/favicon-an.ico
Binary files differ
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..a117ffb0db
--- /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",
+ "browser_specific_settings": {
+ "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
new file mode 100644
index 0000000000..dda80dfd88
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test-extensions/plainengine/favicon.ico
Binary files differ
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..cabb4c9f9a
--- /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",
+ "browser_specific_settings": {
+ "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
new file mode 100644
index 0000000000..82339b3b1d
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test-extensions/special-engine/favicon.ico
Binary files differ
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..1568c6ed55
--- /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",
+ "browser_specific_settings": {
+ "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..741953a1cf
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_SearchStaticData.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests the SearchStaticData module.
+ */
+
+"use strict";
+
+const { SearchStaticData } = ChromeUtils.importESModule(
+ "resource://gre/modules/SearchStaticData.sys.mjs"
+);
+
+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_appDefaultEngine.js b/toolkit/components/search/tests/xpcshell/test_appDefaultEngine.js
new file mode 100644
index 0000000000..0b6fa30fdf
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_appDefaultEngine.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test that appDefaultEngine 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");
+});
+
+add_task(async function test_appDefaultEngine() {
+ await Promise.all([Services.search.init(), promiseAfterSettings()]);
+ Assert.equal(
+ Services.search.appDefaultEngine.name,
+ "Multilocale AN",
+ "Should have returned the correct app 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.appDefaultEngine.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_async.js b/toolkit/components/search/tests/xpcshell/test_async.js
new file mode 100644
index 0000000000..f00ba02755
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_async.js
@@ -0,0 +1,40 @@
+/* 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");
+ Services.fog.initializeFOG();
+});
+
+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);
+
+ // Check if there is a value for startup_time
+ Assert.notEqual(
+ await Glean.searchService.startupTime.testGetValue(),
+ undefined,
+ "Should have a value stored in startup_time"
+ );
+});
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..795dc56324
--- /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.ok(!!engine, "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..24b7b2102a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_defaultEngine.js
@@ -0,0 +1,121 @@
+/* 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;
+
+add_setup(async () => {
+ do_get_profile();
+ Services.fog.initializeFOG();
+
+ useHttpServer();
+ await AddonTestUtils.promiseStartupManager();
+
+ await Services.search.init();
+
+ engine1 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine.xml`,
+ });
+ engine2 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine2.xml`,
+ });
+});
+
+function promiseDefaultNotification() {
+ return SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+}
+
+add_task(async function test_defaultEngine() {
+ let promise = promiseDefaultNotification();
+ Services.search.defaultEngine = engine1;
+ Assert.equal((await promise).wrappedJSObject, engine1);
+ Assert.equal(Services.search.defaultEngine.wrappedJSObject, engine1);
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "other-Test search engine",
+ displayName: "Test search engine",
+ loadPath: "[http]localhost/test-search-engine.xml",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "verified",
+ },
+ });
+
+ promise = promiseDefaultNotification();
+ Services.search.defaultEngine = engine2;
+ Assert.equal((await promise).wrappedJSObject, engine2);
+ Assert.equal(Services.search.defaultEngine.wrappedJSObject, engine2);
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "other-A second test engine",
+ displayName: "A second test engine",
+ loadPath: "[http]localhost/a-second-test-engine.xml",
+ submissionUrl: "https://duckduckgo.com/?q=",
+ verified: "verified",
+ },
+ });
+
+ promise = promiseDefaultNotification();
+ Services.search.defaultEngine = engine1;
+ Assert.equal((await promise).wrappedJSObject, engine1);
+ Assert.equal(Services.search.defaultEngine.wrappedJSObject, engine1);
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "other-Test search engine",
+ displayName: "Test search engine",
+ loadPath: "[http]localhost/test-search-engine.xml",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "verified",
+ },
+ });
+});
+
+add_task(async function test_telemetry_empty_submission_url() {
+ let engine = await Services.search.addOpenSearchEngine(
+ gDataUrl + "../opensearch/simple.xml",
+ null
+ );
+ Services.search.defaultPrivateEngine = engine;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "other-simple",
+ displayName: "simple",
+ loadPath: "[http]localhost/simple.xml",
+ submissionUrl: "blank:",
+ verified: "verified",
+ },
+ private: {
+ engineId: "",
+ displayName: "",
+ loadPath: "",
+ submissionUrl: "blank:",
+ verified: "",
+ },
+ });
+});
+
+add_task(async function test_switch_with_invalid_overriddenBy() {
+ engine1.wrappedJSObject.setAttr("overriddenBy", "random@id");
+
+ consoleAllowList.push(
+ "Test search engine had overriddenBy set, but no _overriddenData"
+ );
+
+ let promise = promiseDefaultNotification();
+ Services.search.defaultEngine = engine2;
+ Assert.equal((await promise).wrappedJSObject, engine2);
+ Assert.equal(Services.search.defaultEngine.wrappedJSObject, engine2);
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_defaultEngine_experiments.js b/toolkit/components/search/tests/xpcshell/test_defaultEngine_experiments.js
new file mode 100644
index 0000000000..59cd0c0942
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_defaultEngine_experiments.js
@@ -0,0 +1,422 @@
+/* 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";
+
+ChromeUtils.defineESModuleGetters(this, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+let getVariableStub;
+
+let defaultGetVariable = name => {
+ if (name == "seperatePrivateDefaultUIEnabled") {
+ return true;
+ }
+ if (name == "seperatePrivateDefaultUrlbarResultEnabled") {
+ return false;
+ }
+ return undefined;
+};
+
+add_setup(async () => {
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ true
+ );
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+
+ sinon.spy(NimbusFeatures.searchConfiguration, "onUpdate");
+ sinon.stub(NimbusFeatures.searchConfiguration, "ready").resolves();
+ getVariableStub = sinon.stub(
+ NimbusFeatures.searchConfiguration,
+ "getVariable"
+ );
+ getVariableStub.callsFake(defaultGetVariable);
+
+ do_get_profile();
+ Services.fog.initializeFOG();
+
+ await SearchTestUtils.useTestEngines("data1");
+
+ await AddonTestUtils.promiseStartupManager();
+
+ let promiseSaved = promiseSaveSettingsData();
+ await Services.search.init();
+ await promiseSaved;
+});
+
+async function switchExperiment(newExperiment) {
+ let promiseReloaded =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+ let promiseSaved = promiseSaveSettingsData();
+
+ // Stub getVariable to populate the cache with our expected data
+ getVariableStub.callsFake(name => {
+ if (name == "experiment") {
+ return newExperiment;
+ }
+ return defaultGetVariable(name);
+ });
+ for (let call of NimbusFeatures.searchConfiguration.onUpdate.getCalls()) {
+ call.args[0]();
+ }
+
+ await promiseReloaded;
+ await promiseSaved;
+}
+
+function getSettingsAttribute(setting) {
+ return Services.search.wrappedJSObject._settings.getVerifiedMetaDataAttribute(
+ setting
+ );
+}
+
+add_task(async function test_experiment_setting() {
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have the application default engine as default"
+ );
+
+ // Start the experiment.
+ await switchExperiment("exp1");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have set the experiment engine as default"
+ );
+
+ // End the experiment.
+ await switchExperiment("");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have reset the default engine to the application default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should have kept the saved attribute as empty"
+ );
+});
+
+add_task(async function test_experiment_setting_to_same_as_user() {
+ Services.search.defaultEngine = Services.search.getEngineByName("engine2");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have the user selected engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "engine2@search.mozilla.orgdefault"
+ );
+
+ // Start the experiment, ensure user default is maintained.
+ await switchExperiment("exp1");
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "engine2@search.mozilla.orgdefault"
+ );
+
+ Assert.equal(
+ Services.search.appDefaultEngine.name,
+ "engine2",
+ "Should have set the experiment engine as default"
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have set the experiment engine as default"
+ );
+
+ // End the experiment.
+ await switchExperiment("");
+
+ Assert.equal(
+ Services.search.appDefaultEngine.name,
+ "engine1",
+ "Should have set the app default engine correctly"
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have kept the engine the same "
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "engine2@search.mozilla.orgdefault",
+ "Should have kept the saved attribute as the user's preference"
+ );
+});
+
+add_task(async function test_experiment_setting_user_changed_back_during() {
+ Services.search.defaultEngine = Services.search.getEngineByName("engine1");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have the application default engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should have an empty settings attribute"
+ );
+
+ // Start the experiment.
+ await switchExperiment("exp1");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have set the experiment engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should still have an empty settings attribute"
+ );
+
+ // User resets to the original default engine.
+ Services.search.defaultEngine = Services.search.getEngineByName("engine1");
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have the user selected engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "engine1@search.mozilla.orgdefault"
+ );
+
+ // Ending the experiment should keep the original default and reset the
+ // saved attribute.
+ await switchExperiment("");
+
+ Assert.equal(
+ Services.search.appDefaultEngine.name,
+ "engine1",
+ "Should have set the app default engine correctly"
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have kept the engine the same"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should have reset the saved attribute to empty after the experiment ended"
+ );
+});
+
+add_task(async function test_experiment_setting_user_changed_back_private() {
+ Services.search.defaultPrivateEngine =
+ Services.search.getEngineByName("engine1");
+
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "engine1",
+ "Should have the user selected engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("privateDefaultEngineId"),
+ "",
+ "Should have an empty settings attribute"
+ );
+
+ // Start the experiment.
+ await switchExperiment("exp2");
+
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "exp2",
+ "Should have set the experiment engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should still have an empty settings attribute"
+ );
+
+ // User resets to the original default engine.
+ Services.search.defaultPrivateEngine =
+ Services.search.getEngineByName("engine1");
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "engine1",
+ "Should have the user selected engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("privateDefaultEngineId"),
+ "engine1@search.mozilla.orgdefault"
+ );
+
+ // Ending the experiment should keep the original default and reset the
+ // saved attribute.
+ await switchExperiment("");
+
+ Assert.equal(Services.search.appPrivateDefaultEngine.name, "engine1");
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have kept the engine the same "
+ );
+ Assert.equal(
+ getSettingsAttribute("privateDefaultEngineId"),
+ "",
+ "Should have reset the saved attribute to empty after the experiment ended"
+ );
+});
+
+add_task(async function test_experiment_setting_user_changed_to_other_during() {
+ Services.search.defaultEngine = Services.search.getEngineByName("engine1");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have the application default engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should have an empty settings attribute"
+ );
+
+ // Start the experiment.
+ await switchExperiment("exp3");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "exp3",
+ "Should have set the experiment engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should still have an empty settings attribute"
+ );
+
+ // User changes to a different default engine
+ Services.search.defaultEngine = Services.search.getEngineByName("engine2");
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have the user selected engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "engine2@search.mozilla.orgdefault",
+ "Should have correctly set the user's default in settings"
+ );
+
+ // Ending the experiment should keep the original default and reset the
+ // saved attribute.
+ await switchExperiment("");
+
+ Assert.equal(
+ Services.search.appDefaultEngine.name,
+ "engine1",
+ "Should have set the app default engine correctly"
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have kept the user's choice of engine"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "engine2@search.mozilla.orgdefault",
+ "Should have kept the user's choice in settings"
+ );
+});
+
+add_task(async function test_experiment_setting_user_hid_app_default_during() {
+ // Add all the test engines to be general search engines. This is important
+ // for the test, as the removed experiment engine needs to be a general search
+ // engine, and the first in the list (aided by the orderHint in
+ // data1/engines.json).
+ SearchUtils.GENERAL_SEARCH_ENGINE_IDS.add("engine1@search.mozilla.org");
+ SearchUtils.GENERAL_SEARCH_ENGINE_IDS.add("engine2@search.mozilla.org");
+ SearchUtils.GENERAL_SEARCH_ENGINE_IDS.add("exp2@search.mozilla.org");
+ SearchUtils.GENERAL_SEARCH_ENGINE_IDS.add("exp3@search.mozilla.org");
+
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ false
+ );
+ Services.search.defaultEngine = Services.search.getEngineByName("engine1");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have the application default engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should have an empty settings attribute"
+ );
+
+ // Start the experiment.
+ await switchExperiment("exp3");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "exp3",
+ "Should have set the experiment engine as default"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "",
+ "Should still have an empty settings attribute"
+ );
+
+ // User hides the original application engine
+ await Services.search.removeEngine(
+ Services.search.getEngineByName("engine1")
+ );
+ Assert.equal(
+ Services.search.getEngineByName("engine1").hidden,
+ true,
+ "Should have hid the selected engine"
+ );
+
+ // Ending the experiment should keep the original default and reset the
+ // saved attribute.
+ await switchExperiment("");
+
+ Assert.equal(
+ Services.search.appDefaultEngine.name,
+ "engine1",
+ "Should have set the app default engine correctly"
+ );
+ Assert.equal(
+ Services.search.defaultEngine.hidden,
+ false,
+ "Should not have set default engine to an engine that is hidden"
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have reset the user's engine to the next available engine"
+ );
+ Assert.equal(
+ getSettingsAttribute("defaultEngineId"),
+ "engine2@search.mozilla.orgdefault",
+ "Should have saved the choice in settings"
+ );
+});
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..3d8a36a2e0
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_defaultEngine_fallback.js
@@ -0,0 +1,406 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test is checking the fallbacks when an engine that is default is
+ * removed or hidden.
+ *
+ * The fallback procedure is:
+ *
+ * - Region/Locale default (if visible)
+ * - First visible engine
+ * - If no other visible engines, unhide the region/locale default and use it.
+ */
+
+let appDefault;
+let appPrivateDefault;
+
+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
+ );
+
+ SearchUtils.GENERAL_SEARCH_ENGINE_IDS = new Set([
+ "engine-resourceicon@search.mozilla.org",
+ "engine-reordered@search.mozilla.org",
+ ]);
+
+ await AddonTestUtils.promiseStartupManager();
+
+ appDefault = await Services.search.getDefault();
+ appPrivateDefault = await Services.search.getDefaultPrivate();
+});
+
+function getDefault(privateMode) {
+ return privateMode
+ ? Services.search.getDefaultPrivate()
+ : Services.search.getDefault();
+}
+
+function setDefault(privateMode, engine) {
+ return privateMode
+ ? Services.search.setDefaultPrivate(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ )
+ : Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+}
+
+async function checkFallbackDefaultRegion(checkPrivate) {
+ let defaultEngine = checkPrivate ? appPrivateDefault : appDefault;
+ let expectedDefaultNotification = checkPrivate
+ ? SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ : SearchUtils.MODIFIED_TYPE.DEFAULT;
+ Services.search.restoreDefaultEngines();
+
+ let otherEngine = Services.search.getEngineByName("engine-chromeicon");
+ await setDefault(checkPrivate, otherEngine);
+
+ Assert.notEqual(
+ otherEngine,
+ defaultEngine,
+ "Sanity check engines are different"
+ );
+
+ const observer = new SearchObserver(
+ [
+ expectedDefaultNotification,
+ // For hiding (removing) the engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.MODIFIED_TYPE.REMOVED,
+ ],
+ expectedDefaultNotification
+ );
+
+ await Services.search.removeEngine(otherEngine);
+
+ let notified = await observer.promise;
+
+ Assert.ok(otherEngine.hidden, "Should have hidden the removed engine");
+ Assert.equal(
+ (await getDefault(checkPrivate)).name,
+ defaultEngine.name,
+ "Should have reverted the defaultEngine to the region default"
+ );
+ Assert.equal(
+ notified.name,
+ defaultEngine.name,
+ "Should have notified the correct default engine"
+ );
+}
+
+add_task(async function test_default_fallback_to_region_default() {
+ await checkFallbackDefaultRegion(false);
+});
+
+add_task(async function test_default_private_fallback_to_region_default() {
+ await checkFallbackDefaultRegion(true);
+});
+
+async function checkFallbackFirstVisible(checkPrivate) {
+ let defaultEngine = checkPrivate ? appPrivateDefault : appDefault;
+ let expectedDefaultNotification = checkPrivate
+ ? SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ : SearchUtils.MODIFIED_TYPE.DEFAULT;
+ Services.search.restoreDefaultEngines();
+
+ let otherEngine = Services.search.getEngineByName("engine-chromeicon");
+ await setDefault(checkPrivate, otherEngine);
+ await Services.search.removeEngine(defaultEngine);
+
+ Assert.notEqual(
+ otherEngine,
+ defaultEngine,
+ "Sanity check engines are different"
+ );
+
+ const observer = new SearchObserver(
+ checkPrivate
+ ? [
+ expectedDefaultNotification,
+ // For hiding (removing) the engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.MODIFIED_TYPE.REMOVED,
+ ]
+ : [
+ expectedDefaultNotification,
+ // For hiding (removing) the engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.MODIFIED_TYPE.REMOVED,
+ ],
+ expectedDefaultNotification
+ );
+
+ await Services.search.removeEngine(otherEngine);
+
+ let notified = await observer.promise;
+
+ Assert.equal(
+ (await getDefault(checkPrivate)).name,
+ "engine-resourceicon",
+ "Should have set the default engine to the first visible general engine"
+ );
+ Assert.equal(
+ notified.name,
+ "engine-resourceicon",
+ "Should have notified the correct default general engine"
+ );
+}
+
+add_task(async function test_default_fallback_to_first_gen_visible() {
+ await checkFallbackFirstVisible(false);
+});
+
+add_task(async function test_default_private_fallback_to_first_gen_visible() {
+ await checkFallbackFirstVisible(true);
+});
+
+// Removing all visible engines affects both the default and private default
+// engines.
+add_task(async function test_default_fallback_when_no_others_visible() {
+ // Remove all but one of the visible engines.
+ let visibleEngines = await Services.search.getVisibleEngines();
+ for (let i = 0; i < visibleEngines.length - 1; i++) {
+ await Services.search.removeEngine(visibleEngines[i]);
+ }
+ Assert.equal(
+ (await Services.search.getVisibleEngines()).length,
+ 1,
+ "Should only have one visible engine"
+ );
+
+ const observer = new SearchObserver(
+ [
+ // Unhiding of the default engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ // Change of the default.
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ // Unhiding of the default private.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE,
+ // Hiding the engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.MODIFIED_TYPE.REMOVED,
+ ],
+ SearchUtils.MODIFIED_TYPE.DEFAULT
+ );
+
+ // Now remove the last engine, which should set the new default.
+ await Services.search.removeEngine(visibleEngines[visibleEngines.length - 1]);
+
+ let notified = await observer.promise;
+
+ Assert.equal(
+ (await getDefault(false)).name,
+ appDefault.name,
+ "Should fallback to the app default engine after removing all engines"
+ );
+ Assert.equal(
+ (await getDefault(true)).name,
+ appPrivateDefault.name,
+ "Should fallback to the app default private engine after removing all engines"
+ );
+ Assert.equal(
+ notified.name,
+ appDefault.name,
+ "Should have notified the correct default engine"
+ );
+ Assert.ok(
+ !appPrivateDefault.hidden,
+ "Should have unhidden the app default private engine"
+ );
+ Assert.equal(
+ (await Services.search.getVisibleEngines()).length,
+ 2,
+ "Should now have two engines visible"
+ );
+});
+
+add_task(async function test_default_fallback_remove_default_no_visible() {
+ // Remove all but the default engine.
+ Services.search.defaultPrivateEngine = Services.search.defaultEngine;
+ let visibleEngines = await Services.search.getVisibleEngines();
+ for (let engine of visibleEngines) {
+ if (engine.name != appDefault.name) {
+ await Services.search.removeEngine(engine);
+ }
+ }
+ Assert.equal(
+ (await Services.search.getVisibleEngines()).length,
+ 1,
+ "Should only have one visible engine"
+ );
+
+ const observer = new SearchObserver(
+ [
+ // Unhiding of the default engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ // Change of the default.
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE,
+ // Hiding the engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.MODIFIED_TYPE.REMOVED,
+ ],
+ SearchUtils.MODIFIED_TYPE.DEFAULT
+ );
+
+ // Now remove the last engine, which should set the new default.
+ await Services.search.removeEngine(appDefault);
+
+ let notified = await observer.promise;
+
+ Assert.equal(
+ (await getDefault(false)).name,
+ "engine-resourceicon",
+ "Should fallback the default engine to the first general search engine"
+ );
+ Assert.equal(
+ (await getDefault(true)).name,
+ "engine-resourceicon",
+ "Should fallback the default private engine to the first general search engine"
+ );
+ Assert.equal(
+ notified.name,
+ "engine-resourceicon",
+ "Should have notified the correct default engine"
+ );
+ Assert.ok(
+ !Services.search.getEngineByName("engine-resourceicon").hidden,
+ "Should have unhidden the new engine"
+ );
+ Assert.equal(
+ (await Services.search.getVisibleEngines()).length,
+ 1,
+ "Should now have one engines visible"
+ );
+});
+
+add_task(
+ async function test_default_fallback_remove_default_no_visible_or_general() {
+ // Reset.
+ Services.search.restoreDefaultEngines();
+ Services.search.defaultEngine = Services.search.defaultPrivateEngine =
+ appPrivateDefault;
+
+ // Remove all but the default engine.
+ let visibleEngines = await Services.search.getVisibleEngines();
+ for (let engine of visibleEngines) {
+ if (engine.name != appPrivateDefault.name) {
+ await Services.search.removeEngine(engine);
+ }
+ }
+ Assert.deepEqual(
+ (await Services.search.getVisibleEngines()).map(e => e.name),
+ appPrivateDefault.name,
+ "Should only have one visible engine"
+ );
+
+ SearchUtils.GENERAL_SEARCH_ENGINE_IDS.clear();
+
+ const observer = new SearchObserver(
+ [
+ // Unhiding of the default engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ // Change of the default.
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE,
+ // Hiding the engine.
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.MODIFIED_TYPE.REMOVED,
+ ],
+ SearchUtils.MODIFIED_TYPE.DEFAULT
+ );
+
+ // Now remove the last engine, which should set the new default.
+ await Services.search.removeEngine(appPrivateDefault);
+
+ let notified = await observer.promise;
+
+ Assert.equal(
+ (await getDefault(false)).name,
+ "Test search engine",
+ "Should fallback to the first engine that isn't a general search engine"
+ );
+ Assert.equal(
+ (await getDefault(true)).name,
+ "Test search engine",
+ "Should fallback the private engine to the first engine that isn't a general search engine"
+ );
+ Assert.equal(
+ notified.name,
+ "Test search engine",
+ "Should have notified the correct default engine"
+ );
+ Assert.ok(
+ !Services.search.getEngineByName("Test search engine").hidden,
+ "Should have unhidden the new engine"
+ );
+ Assert.equal(
+ (await Services.search.getVisibleEngines()).length,
+ 1,
+ "Should now have one engines visible"
+ );
+ }
+);
+
+// Test the other remove engine route - for removing non-application provided
+// engines.
+
+async function checkNonBuiltinFallback(checkPrivate) {
+ let defaultEngine = checkPrivate ? appPrivateDefault : appDefault;
+ let expectedDefaultNotification = checkPrivate
+ ? SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE
+ : SearchUtils.MODIFIED_TYPE.DEFAULT;
+ Services.search.restoreDefaultEngines();
+
+ let addedEngine = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine2.xml`,
+ });
+
+ await setDefault(checkPrivate, addedEngine);
+
+ const observer = new SearchObserver(
+ [expectedDefaultNotification, SearchUtils.MODIFIED_TYPE.REMOVED],
+ expectedDefaultNotification
+ );
+
+ // Remove the current engine...
+ await Services.search.removeEngine(addedEngine);
+
+ // ... and verify we've reverted to the normal default engine.
+ Assert.equal(
+ (await getDefault(checkPrivate)).name,
+ defaultEngine.name,
+ "Should revert to the app default engine"
+ );
+
+ let notified = await observer.promise;
+ Assert.equal(
+ notified.name,
+ defaultEngine.name,
+ "Should have notified the correct 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..74c0dc5188
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_defaultPrivateEngine.js
@@ -0,0 +1,582 @@
+/* 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 appDefault;
+let appPrivateDefault;
+
+add_setup(async () => {
+ do_get_profile();
+ Services.fog.initializeFOG();
+
+ 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("opensearch");
+ await AddonTestUtils.promiseStartupManager();
+
+ await Services.search.init();
+
+ appDefault = Services.search.appDefaultEngine;
+ appPrivateDefault = Services.search.appPrivateDefaultEngine;
+ 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,
+ appPrivateDefault,
+ "Should have the app private default as the default private engine"
+ );
+ Assert.equal(
+ Services.search.defaultEngine,
+ appDefault,
+ "Should have the app default as the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ displayName: "Test search engine",
+ loadPath: "[addon]engine@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "default",
+ },
+ private: {
+ engineId: "engine-pref",
+ displayName: "engine-pref",
+ loadPath: "[addon]engine-pref@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "default",
+ },
+ });
+
+ 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,
+ appDefault,
+ "Should not have changed the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ displayName: "Test search engine",
+ loadPath: "[addon]engine@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "default",
+ },
+ private: {
+ engineId: "engine-rel-searchform-purpose",
+ displayName: "engine-rel-searchform-purpose",
+ loadPath: "[addon]engine-rel-searchform-purpose@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=&channel=sb",
+ verified: "default",
+ },
+ });
+
+ promise = promiseDefaultNotification("private");
+ await Services.search.setDefaultPrivate(
+ engine2,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ 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,
+ appDefault,
+ "Should not have changed the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ displayName: "Test search engine",
+ loadPath: "[addon]engine@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "default",
+ },
+ private: {
+ engineId: "engine-chromeicon",
+ displayName: "engine-chromeicon",
+ loadPath: "[addon]engine-chromeicon@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "default",
+ },
+ });
+
+ promise = promiseDefaultNotification("private");
+ await Services.search.setDefaultPrivate(
+ engine1,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ 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"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ },
+ private: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ });
+
+ engine1.hidden = true;
+ Assert.equal(
+ Services.search.defaultPrivateEngine,
+ appPrivateDefault,
+ "Should reset to the app default private engine when hiding the default"
+ );
+ Assert.equal(
+ Services.search.defaultEngine,
+ appDefault,
+ "Should not have changed the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ },
+ private: {
+ engineId: "engine-pref",
+ },
+ });
+
+ engine1.hidden = false;
+ Services.search.defaultEngine = engine1;
+ Assert.equal(
+ Services.search.defaultPrivateEngine,
+ appPrivateDefault,
+ "Setting the default engine should not affect the private default"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ private: {
+ engineId: "engine-pref",
+ },
+ });
+
+ Services.search.defaultEngine = appDefault;
+});
+
+add_task(async function test_telemetry_private_empty_submission_url() {
+ let engine = await Services.search.addOpenSearchEngine(
+ gDataUrl + "simple.xml",
+ null
+ );
+ Services.search.defaultPrivateEngine = engine;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: appDefault.telemetryId,
+ },
+ private: {
+ engineId: "other-simple",
+ displayName: "simple",
+ loadPath: "[http]localhost/simple.xml",
+ submissionUrl: "blank:",
+ verified: "verified",
+ },
+ });
+
+ Services.search.defaultEngine = appDefault;
+});
+
+add_task(async function test_defaultPrivateEngine_turned_off() {
+ Services.search.defaultEngine = appDefault;
+ Services.search.defaultPrivateEngine = engine1;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ },
+ private: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ });
+
+ let promise = promiseDefaultNotification("private");
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ false
+ );
+ Assert.equal(
+ await promise,
+ appDefault,
+ "Should have notified setting the first engine correctly."
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ },
+ private: {
+ engineId: "",
+ },
+ });
+
+ 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"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ private: {
+ engineId: "",
+ },
+ });
+
+ 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"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ private: {
+ engineId: "engine-chromeicon",
+ },
+ });
+
+ 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"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ private: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ });
+});
+
+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;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ });
+
+ 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."
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "",
+ },
+ });
+
+ 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"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-rel-searchform-purpose",
+ },
+ private: {
+ engineId: "",
+ },
+ });
+});
+
+add_task(async function test_defaultPrivateEngine_same_engine_toggle_pref() {
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ true
+ );
+
+ // Set the normal and private engines to be the same
+ Services.search.defaultEngine = engine2;
+ Services.search.defaultPrivateEngine = engine2;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "engine-chromeicon",
+ },
+ });
+
+ // Disable pref
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ false
+ );
+ Assert.equal(
+ Services.search.defaultPrivateEngine,
+ engine2,
+ "Should not change the default private engine"
+ );
+ Assert.equal(
+ Services.search.defaultEngine,
+ engine2,
+ "Should not change the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "",
+ },
+ });
+
+ // Re-enable pref
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+ Assert.equal(
+ Services.search.defaultPrivateEngine,
+ engine2,
+ "Should not change the default private engine"
+ );
+ Assert.equal(
+ Services.search.defaultEngine,
+ engine2,
+ "Should not change the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "engine-chromeicon",
+ },
+ });
+});
+
+add_task(async function test_defaultPrivateEngine_same_engine_toggle_ui_pref() {
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ true
+ );
+
+ // Set the normal and private engines to be the same
+ Services.search.defaultEngine = engine2;
+ Services.search.defaultPrivateEngine = engine2;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "engine-chromeicon",
+ },
+ });
+
+ // Disable UI pref
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ false
+ );
+ Assert.equal(
+ Services.search.defaultPrivateEngine,
+ engine2,
+ "Should not change the default private engine"
+ );
+ Assert.equal(
+ Services.search.defaultEngine,
+ engine2,
+ "Should not change the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "",
+ },
+ });
+
+ // Re-enable UI pref
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ true
+ );
+ Assert.equal(
+ Services.search.defaultPrivateEngine,
+ engine2,
+ "Should not change the default private engine"
+ );
+ Assert.equal(
+ Services.search.defaultEngine,
+ engine2,
+ "Should not change the default engine"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine-chromeicon",
+ },
+ private: {
+ engineId: "engine-chromeicon",
+ },
+ });
+});
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..62c3c141c1
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_alias.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+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();
+ await SearchTestUtils.installSearchExtension({
+ name: NAME,
+ keyword: "testalias",
+ });
+ 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");
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_engine_ids.js b/toolkit/components/search/tests/xpcshell/test_engine_ids.js
new file mode 100644
index 0000000000..c6e5e7e148
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_ids.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests Search Engine IDs are created correctly.
+ */
+
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+const CONFIG = [
+ {
+ webExtension: {
+ id: "engine@search.mozilla.org",
+ },
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ default: "yes",
+ },
+ ],
+ },
+];
+
+add_task(async function setup() {
+ useHttpServer("opensearch");
+ await SearchTestUtils.useTestEngines("data", null, CONFIG);
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+});
+
+add_task(async function test_add_on_engine_id() {
+ let addOnEngine = Services.search.defaultEngine;
+
+ Assert.equal(
+ addOnEngine.name,
+ "Test search engine",
+ "Should have installed the Test search engine as default."
+ );
+ Assert.ok(addOnEngine.id, "The Addon Search Engine should have an id.");
+ Assert.equal(
+ addOnEngine.id,
+ "engine@search.mozilla.orgdefault",
+ "The Addon Search Engine id should be the webextension id + the locale."
+ );
+});
+
+add_task(async function test_user_engine_id() {
+ let promiseEngineAdded = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.ADDED,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ await Services.search.addUserEngine(
+ "user",
+ "https://example.com/user?q={searchTerms}",
+ "u"
+ );
+
+ await promiseEngineAdded;
+ let userEngine = Services.search.getEngineByName("user");
+
+ Assert.ok(userEngine, "Should have installed the User Search Engine.");
+ Assert.ok(userEngine.id, "The User Search Engine should have an id.");
+ Assert.equal(
+ userEngine.id.length,
+ 36,
+ "The User Search Engine id should be a 36 character uuid."
+ );
+});
+
+add_task(async function test_open_search_engine_id() {
+ let promiseEngineAdded = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.ADDED,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ let openSearchEngine = await Services.search.addOpenSearchEngine(
+ gDataUrl + "simple.xml",
+ null
+ );
+
+ await promiseEngineAdded;
+
+ Assert.ok(openSearchEngine, "Should have installed the Open Search Engine.");
+ Assert.ok(openSearchEngine.id, "The Open Search Engine should have an id.");
+ Assert.equal(
+ openSearchEngine.id.length,
+ 36,
+ "The Open Search Engine id should be a 36 character uuid."
+ );
+});
+
+add_task(async function test_enterprise_policy_engine_id() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "policy",
+ Description: "Test policy engine",
+ IconURL: "data:image/gif;base64,R0lGODl",
+ Alias: "p",
+ URLTemplate: "https://example.com?q={searchTerms}",
+ SuggestURLTemplate: "https://example.com/suggest/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+
+ let policyEngine = Services.search.getEngineByName("policy");
+
+ Assert.ok(policyEngine, "Should have installed the Policy Engine.");
+ Assert.ok(policyEngine.id, "The Policy Engine should have an id.");
+ Assert.equal(
+ policyEngine.id,
+ "policy-policy",
+ "The Policy Engine id should be 'policy-' + 'the name of the policy engine'."
+ );
+});
+
+/**
+ * Loads a new enterprise policy, and re-initialise the search service
+ * with the new policy. Also waits for the search service to write the settings
+ * file to disk.
+ *
+ * @param {object} policy
+ * The enterprise policy to use.
+ */
+async function setupPolicyEngineWithJson(policy) {
+ Services.search.wrappedJSObject.reset();
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(policy);
+
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await Services.search.init();
+ await settingsWritten;
+}
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..f4a7905d45
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_multiple_alias.js
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+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();
+ await SearchTestUtils.installSearchExtension({
+ name: NAME,
+ keyword: [" test", "alias "],
+ });
+ 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");
+});
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..7052a7de76
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_selector.js
@@ -0,0 +1,241 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+const TEST_CONFIG = [
+ {
+ engineName: "aol",
+ orderHint: 500,
+ webExtension: {
+ locales: ["default"],
+ },
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ },
+ {
+ included: { regions: ["us"] },
+ webExtension: {
+ locales: ["baz-$USER_LOCALE"],
+ },
+ telemetryId: "foo-$USER_LOCALE",
+ },
+ {
+ included: { regions: ["fr"] },
+ webExtension: {
+ locales: ["region-$USER_REGION"],
+ },
+ telemetryId: "bar-$USER_REGION",
+ },
+ {
+ included: { regions: ["be"] },
+ webExtension: {
+ locales: ["$USER_LOCALE"],
+ },
+ telemetryId: "$USER_LOCALE",
+ },
+ {
+ included: { regions: ["au"] },
+ webExtension: {
+ locales: ["$USER_REGION"],
+ },
+ telemetryId: "$USER_REGION",
+ },
+ ],
+ },
+ {
+ 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 setup() {
+ const settings = await RemoteSettings(SearchUtils.SETTINGS_KEY);
+ sinon.stub(settings, "get").returns(TEST_CONFIG);
+});
+
+add_task(async function test_engine_selector() {
+ 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,
+ "baz-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"
+ );
+});
+
+add_task(async function test_locale_region_replacement() {
+ let { engines } = await engineSelector.fetchEngineConfiguration({
+ locale: "en-US",
+ region: "us",
+ });
+ let engine = engines.find(e => e.engineName == "aol");
+ Assert.equal(
+ engine.webExtension.locale,
+ "baz-en-US",
+ "The locale is correctly inserted into the locale field"
+ );
+ Assert.equal(
+ engine.telemetryId,
+ "foo-en-US",
+ "The locale is correctly inserted into the telemetryId"
+ );
+
+ ({ engines } = await engineSelector.fetchEngineConfiguration({
+ locale: "it",
+ region: "us",
+ }));
+ engine = engines.find(e => e.engineName == "aol");
+
+ Assert.equal(
+ engines.find(e => e.engineName == "aol").webExtension.locale,
+ "baz-it",
+ "The locale is correctly inserted into the locale field"
+ );
+ Assert.equal(
+ engine.telemetryId,
+ "foo-it",
+ "The locale is correctly inserted into the telemetryId"
+ );
+
+ ({ engines } = await engineSelector.fetchEngineConfiguration({
+ locale: "en-CA",
+ region: "fr",
+ }));
+ engine = engines.find(e => e.engineName == "aol");
+ Assert.equal(
+ engine.webExtension.locale,
+ "region-fr",
+ "The region is correctly inserted into the locale field"
+ );
+ Assert.equal(
+ engine.telemetryId,
+ "bar-fr",
+ "The region is correctly inserted into the telemetryId"
+ );
+
+ ({ engines } = await engineSelector.fetchEngineConfiguration({
+ locale: "fy-NL",
+ region: "be",
+ }));
+ engine = engines.find(e => e.engineName == "aol");
+ Assert.equal(
+ engine.webExtension.locale,
+ "fy-NL",
+ "The locale is correctly inserted into the locale field"
+ );
+ Assert.equal(
+ engine.telemetryId,
+ "fy-NL",
+ "The locale is correctly inserted into the telemetryId"
+ );
+ ({ engines } = await engineSelector.fetchEngineConfiguration({
+ locale: "en-US",
+ region: "au",
+ }));
+ engine = engines.find(e => e.engineName == "aol");
+ Assert.equal(
+ engine.webExtension.locale,
+ "au",
+ "The region is correctly inserted into the locale field"
+ );
+ Assert.equal(
+ engine.telemetryId,
+ "au",
+ "The region is correctly inserted into the telemetryId"
+ );
+});
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..ef54b763af
--- /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";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+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..2e29eac198
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_application_distribution.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+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..fef03c47be
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_application_name.js
@@ -0,0 +1,126 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+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",
+ },
+ ],
+ },
+];
+
+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) {
+ let { engines } = await engineSelector.fetchEngineConfiguration({
+ locale: "default",
+ region: "default",
+ name,
+ version,
+ });
+ 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..1de4792af1
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_order.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+/**
+ * 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..e9aa1c073f
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_selector_override.js
@@ -0,0 +1,181 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+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",
+ },
+ {
+ webExtension: {
+ id: "altavista@example.com",
+ },
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ },
+ {
+ override: true,
+ application: {
+ distributions: ["distro2"],
+ },
+ included: { regions: ["gb"] },
+ params: {
+ searchUrlGetParams: [
+ {
+ name: "field-keywords2",
+ value: "{searchTerms}",
+ },
+ ],
+ },
+ },
+ ],
+ 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."
+ );
+});
+
+add_task(async function test_engine_selector_override_with_included() {
+ let { engines } = await engineSelector.fetchEngineConfiguration({
+ locale: "en-US",
+ region: "us",
+ distroID: "distro2",
+ });
+
+ let engine = engines.find(e => e.webExtension.id == "altavista@example.com");
+ Assert.ok(
+ !("params" in engine),
+ "Should not have overriden the parameters of the engine."
+ );
+
+ let result = await engineSelector.fetchEngineConfiguration({
+ locale: "en-US",
+ region: "gb",
+ distroID: "distro2",
+ });
+ engine = result.engines.find(
+ e => e.webExtension.id == "altavista@example.com"
+ );
+ Assert.deepEqual(
+ engine.params,
+ {
+ searchUrlGetParams: [
+ {
+ name: "field-keywords2",
+ value: "{searchTerms}",
+ },
+ ],
+ },
+ "Should have overriden the parameters 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..336ffb1ee5
--- /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";
+
+ChromeUtils.defineESModuleGetters(this, {
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+ SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.sys.mjs",
+});
+
+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 = PromiseUtils.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 = RemoteSettings(SearchUtils.SETTINGS_KEY).db;
+ await db.importChanges(
+ {},
+ Date.now(),
+ [
+ {
+ 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(
+ {},
+ Date.now(),
+ [
+ {
+ 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 = RemoteSettings(SearchUtils.SETTINGS_KEY).db;
+ await db.importChanges(
+ {},
+ Date.now(),
+ [
+ {
+ 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..2732183517
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_engine_set_alias.js
@@ -0,0 +1,132 @@
+"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 extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "bacon",
+ keyword: "b",
+ search_url: "https://www.bacon.test/find",
+ },
+ { skipUnload: true }
+ );
+ let engine1 = await Services.search.getEngineByName("bacon");
+ Assert.ok(engine1.aliases.includes("b"));
+ engine1.alias = "a";
+ Assert.equal(engine1.alias, "a");
+ await extension.unload();
+});
+
+add_task(async function test_engine_set_alias_with_left_space() {
+ info("Set engine alias with left space");
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "bacon",
+ keyword: " a",
+ search_url: "https://www.bacon.test/find",
+ },
+ { skipUnload: true }
+ );
+ let engine2 = await Services.search.getEngineByName("bacon");
+ Assert.ok(engine2.aliases.includes("a"));
+ engine2.alias = " c";
+ Assert.equal(engine2.alias, "c");
+ await extension.unload();
+});
+
+add_task(async function test_engine_set_alias_with_right_space() {
+ info("Set engine alias with right space");
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "bacon",
+ keyword: "c ",
+ search_url: "https://www.bacon.test/find",
+ },
+ { skipUnload: true }
+ );
+ let engine3 = await Services.search.getEngineByName("bacon");
+ Assert.ok(engine3.aliases.includes("c"));
+ engine3.alias = "o ";
+ Assert.equal(engine3.alias, "o");
+ await extension.unload();
+});
+
+add_task(async function test_engine_set_alias_with_right_left_space() {
+ info("Set engine alias with left and right space");
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "bacon",
+ keyword: " o ",
+ search_url: "https://www.bacon.test/find",
+ },
+ { skipUnload: true }
+ );
+ let engine4 = await Services.search.getEngineByName("bacon");
+ Assert.ok(engine4.aliases.includes("o"));
+ engine4.alias = " n ";
+ Assert.equal(engine4.alias, "n");
+ await extension.unload();
+});
+
+add_task(async function test_engine_set_alias_with_space() {
+ info("Set engine alias with space");
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "bacon",
+ keyword: " ",
+ search_url: "https://www.bacon.test/find",
+ },
+ { skipUnload: true }
+ );
+ let engine5 = await Services.search.getEngineByName("bacon");
+ Assert.equal(engine5.alias, "");
+ engine5.alias = "b";
+ Assert.equal(engine5.alias, "b");
+ engine5.alias = " ";
+ Assert.equal(engine5.alias, "");
+ await extension.unload();
+});
+
+add_task(async function test_engine_change_alias() {
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "bacon",
+ keyword: " o ",
+ search_url: "https://www.bacon.test/find",
+ },
+ { skipUnload: true }
+ );
+ let engine6 = await Services.search.getEngineByName("bacon");
+
+ let promise = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ engine6.alias = "ba";
+
+ await promise;
+ Assert.equal(
+ engine6.alias,
+ "ba",
+ "Should have correctly notified and changed the alias."
+ );
+
+ let observed = false;
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ observed = true;
+ }, SearchUtils.TOPIC_ENGINE_MODIFIED);
+
+ engine6.alias = "ba";
+
+ Assert.equal(engine6.alias, "ba", "Should have not changed the alias");
+ Assert.ok(!observed, "Should not have notified for no change in alias");
+
+ await extension.unload();
+});
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..b868f1aa8b
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_getSubmission_encoding.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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..648267e5fc
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_getSubmission_params.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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: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() {
+ 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:locale}", "{moz:locale}");
+
+ 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..72fa052211
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_identifiers.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test of a search engine's identifier.
+ */
+
+"use strict";
+
+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"
+ );
+
+ useHttpServer();
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine.xml`,
+ });
+});
+
+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..cf00e7ca30
--- /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 =
+ "https://example.com/?search={searchTerms}&ignore=true";
+const kSearchEngineURL2 =
+ "https://example.com/?search={searchTerms}&IGNORE=TRUE";
+const kSearchEngineURL3 = "https://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 SearchTestUtils.installSearchExtension({
+ name: kSearchEngineID1,
+ search_url: kSearchEngineURL1,
+ });
+
+ await updatePromise;
+
+ let engine = Services.search.getEngineByName(kSearchEngineID1);
+ Assert.equal(
+ engine,
+ null,
+ "Engine with ignored search params should not exist"
+ );
+
+ await SearchTestUtils.installSearchExtension({
+ name: kSearchEngineID2,
+ search_url: 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 SearchTestUtils.installSearchExtension({
+ id: kExtensionID,
+ name: kSearchEngineID3,
+ search_url: kSearchEngineURL3,
+ });
+
+ // 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..4a64bd469e
--- /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 =
+ "https://example.com/?search={searchTerms}&ignore=true";
+const kSearchEngineURL2 =
+ "https://example.com/?search={searchTerms}&IGNORE=TRUE";
+const kSearchEngineURL3 = "https://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 SearchTestUtils.installSearchExtension({
+ name: kSearchEngineID1,
+ search_url: kSearchEngineURL1,
+ });
+ await SearchTestUtils.installSearchExtension({
+ name: kSearchEngineID2,
+ search_url: kSearchEngineURL2,
+ });
+ await SearchTestUtils.installSearchExtension({
+ id: kExtensionID,
+ name: kSearchEngineID3,
+ search_url: kSearchEngineURL3,
+ });
+
+ // 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: ["[addon]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..99505bdeb5
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_initialization.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that a delayed add-on manager start up does not affect the start up
+// of the search service.
+
+"use strict";
+
+const CONFIG = [
+ {
+ webExtension: {
+ id: "engine@search.mozilla.org",
+ },
+ orderHint: 30,
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ default: "yes",
+ },
+ ],
+ },
+];
+
+add_setup(() => {
+ do_get_profile();
+ Services.fog.initializeFOG();
+});
+
+add_task(async function test_initialization_delayed_addon_manager() {
+ let stub = await SearchTestUtils.useTestEngines("data", null, CONFIG);
+ // Wait until the search service gets its configuration before starting
+ // to initialise the add-on manager. This simulates the add-on manager
+ // starting late which used to cause the search service to fail to load any
+ // engines.
+ stub.callsFake(() => {
+ Services.tm.dispatchToMainThread(() => {
+ AddonTestUtils.promiseStartupManager();
+ });
+ return CONFIG;
+ });
+
+ await Services.search.init();
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "Test search engine",
+ "Test engine shouldn't be the default anymore"
+ );
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "engine",
+ displayName: "Test search engine",
+ loadPath: "[addon]engine@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+ verified: "default",
+ },
+ });
+});
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..8de8ad6a34
--- /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.appDefaultEngine.name,
+ "engine1",
+ "Should have the expected engine as app default"
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have the expected engine as default"
+ );
+ Assert.equal(
+ Services.search.appPrivateDefaultEngine.name,
+ "engine1",
+ "Should have the same engine for the app 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..38c7f302d2
--- /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.appDefaultEngine.name,
+ "Test search engine",
+ "Should have the expected engine as the app default"
+ );
+
+ // 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.appPrivateDefaultEngine.name,
+ Services.search.appDefaultEngine.name,
+ "Should have the normal app 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.appPrivateDefaultEngine.name,
+ "engine-pref",
+ "Should have the app 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..8d0a0891b8
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_maybereloadengine_order.js
@@ -0,0 +1,53 @@
+/* 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.getMetaDataAttribute(
+ "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..65de325924
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_migrateWebExtensionEngine.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const kExtensionID = "simple@tests.mozilla.org";
+
+add_task(async function setup() {
+ useHttpServer("opensearch");
+ await AddonTestUtils.promiseStartupManager();
+ await SearchTestUtils.useTestEngines("data1");
+ await Services.search.init();
+});
+
+add_task(async function test_migrateLegacyEngine() {
+ await Services.search.addOpenSearchEngine(gDataUrl + "simple.xml", null);
+
+ // Modify the loadpath so it looks like a legacy plugin loadpath
+ let engine = Services.search.getEngineByName("simple");
+ engine.wrappedJSObject._loadPath = `jar:[profile]/extensions/${kExtensionID}.xpi!/simple.xml`;
+ engine.wrappedJSObject._extensionID = null;
+
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ // This should replace the existing engine
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ id: "simple",
+ name: "simple",
+ search_url: "https://example.com/",
+ },
+ { skipUnload: true }
+ );
+
+ engine = Services.search.getEngineByName("simple");
+ Assert.equal(engine.wrappedJSObject._loadPath, "[addon]" + kExtensionID);
+ Assert.equal(engine.wrappedJSObject._extensionID, kExtensionID);
+
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ "simple",
+ "Should have kept the default engine the same"
+ );
+
+ await extension.unload();
+});
+
+add_task(async function test_migrateLegacyEngineDifferentName() {
+ await Services.search.addOpenSearchEngine(gDataUrl + "simple.xml", null);
+
+ // Modify the loadpath so it looks like an legacy plugin loadpath
+ let engine = Services.search.getEngineByName("simple");
+ engine.wrappedJSObject._loadPath = `jar:[profile]/extensions/${kExtensionID}.xpi!/simple.xml`;
+ engine.wrappedJSObject._extensionID = null;
+
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ // This should replace the existing engine - it has the same id, but a different name.
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ id: "simple",
+ name: "simple search",
+ search_url: "https://example.com/",
+ },
+ { skipUnload: true }
+ );
+
+ engine = Services.search.getEngineByName("simple");
+ Assert.equal(engine, null, "Should have removed the old engine");
+
+ // The engine should have changed its name.
+ engine = Services.search.getEngineByName("simple search");
+ Assert.equal(engine.wrappedJSObject._loadPath, "[addon]" + kExtensionID);
+ Assert.equal(engine.wrappedJSObject._extensionID, kExtensionID);
+
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ "simple search",
+ "Should have made the new engine default"
+ );
+
+ await extension.unload();
+});
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..9be8c79ae8
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_missing_engine.js
@@ -0,0 +1,104 @@
+/* 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 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 },
+ },
+ ],
+ },
+];
+
+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() {
+ 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..dc9cc24add
--- /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 SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}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..155079ce4b
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_nodb_pluschanges.js
@@ -0,0 +1,52 @@
+/* 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 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine.xml`,
+ });
+ let engine2 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}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..6f7ee3243a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_notifications.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let engine;
+let appDefaultEngine;
+
+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
+ );
+
+ appDefaultEngine = 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,
+ ],
+ SearchUtils.MODIFIED_TYPE.ADDED
+ );
+
+ 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,
+ ],
+ SearchUtils.MODIFIED_TYPE.ADDED
+ );
+
+ await SearchTestUtils.installSearchExtension({
+ name: "Example Engine",
+ });
+
+ let webExtensionEngine = await addEngineObserver.promise;
+
+ let retrievedEngine = Services.search.getEngineByName("Example Engine");
+ Assert.equal(webExtensionEngine, retrievedEngine);
+});
+
+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(
+ appDefaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ false
+ );
+
+ await defaultNotificationTest(true, true);
+ }
+);
+
+add_task(async function test_removeEngine() {
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ await Services.search.setDefaultPrivate(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ const removedObserver = new SearchObserver([
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ SearchUtils.MODIFIED_TYPE.DEFAULT_PRIVATE,
+ 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..bdd42860af
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_opensearch.js
@@ -0,0 +1,160 @@
+/* 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() {
+ Services.fog.initializeFOG();
+ 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");
+ }
+ });
+}
+
+add_task(async function test_telemetry_reporting() {
+ // Use an engine from the previous tests.
+ let engine = Services.search.getEngineByName("simple");
+ Services.search.defaultEngine = engine;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "other-simple",
+ displayName: "simple",
+ loadPath: "[http]localhost/simple.xml",
+ submissionUrl: "blank:",
+ verified: "verified",
+ },
+ });
+});
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..937d1a58b9
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_opensearch_icon.js
@@ -0,0 +1,63 @@
+/* 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.
+ SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}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..4f81b0e9d3
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_opensearch_icons_invalid.js
@@ -0,0 +1,49 @@
+/* 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() {
+ let server = useHttpServer("");
+ server.registerContentType("sjs", "sjs");
+ await AddonTestUtils.promiseStartupManager();
+});
+
+add_task(async function test_installedresourceicon() {
+ let engine1 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}opensearch/resourceicon.xml`,
+ });
+ let engine2 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}opensearch/chromeicon.xml`,
+ });
+
+ Assert.equal(null, engine1.iconURI);
+ Assert.equal(null, engine2.iconURI);
+});
+
+add_task(async function test_installedhttpplace() {
+ let observed = TestUtils.consoleMessageObserved(msg => {
+ return msg.wrappedJSObject.arguments[0].includes(
+ "Content type does not match expected"
+ );
+ });
+
+ // 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.
+ let engine = await SearchTestUtils.promiseNewSearchEngine({
+ url:
+ `${gDataUrl}data/engineMaker.sjs?` +
+ JSON.stringify({
+ baseURL: gDataUrl,
+ image: "opensearch/resourceicon.xml",
+ name: "invalidicon",
+ method: "GET",
+ }),
+ });
+
+ await observed;
+
+ Assert.equal(null, engine.iconURI, "Should not have set an 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..62ae7d18ce
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_opensearch_install_errors.js
@@ -0,0 +1,67 @@
+/* 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!");
+ consoleAllowList.push("Invalid search plugin due to namespace not matching");
+});
+
+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_telemetry.js b/toolkit/components/search/tests/xpcshell/test_opensearch_telemetry.js
new file mode 100644
index 0000000000..cd42a47371
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_opensearch_telemetry.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const { promiseStartupManager, promiseShutdownManager } = AddonTestUtils;
+
+const openSearchEngineFiles = [
+ "secure-and-securely-updated1.xml",
+ "secure-and-securely-updated2.xml",
+ "secure-and-securely-updated3.xml",
+ // An insecure search form should not affect telemetry.
+ "secure-and-securely-updated-insecure-form.xml",
+ "secure-and-insecurely-updated1.xml",
+ "secure-and-insecurely-updated2.xml",
+ "insecure-and-securely-updated1.xml",
+ "insecure-and-insecurely-updated1.xml",
+ "insecure-and-insecurely-updated2.xml",
+ "secure-and-no-update-url1.xml",
+ "insecure-and-no-update-url1.xml",
+ "secure-localhost.xml",
+ "secure-onionv2.xml",
+ "secure-onionv3.xml",
+];
+
+async function verifyTelemetry(probeNameFragment, engineCount, type) {
+ Services.telemetry.clearScalars();
+ await Services.search.runBackgroundChecks();
+
+ TelemetryTestUtils.assertScalar(
+ TelemetryTestUtils.getProcessScalars("parent"),
+ `browser.searchinit.${probeNameFragment}`,
+ engineCount,
+ `Count of ${type} engines: ${engineCount}`
+ );
+}
+
+add_task(async function setup() {
+ useHttpServer("opensearch");
+
+ await promiseStartupManager();
+ await Services.search.init();
+
+ for (let file of openSearchEngineFiles) {
+ await Services.search.addOpenSearchEngine(gDataUrl + file, null);
+ }
+
+ registerCleanupFunction(async () => {
+ await promiseShutdownManager();
+ });
+});
+
+add_task(async function () {
+ verifyTelemetry("secure_opensearch_engine_count", 10, "secure");
+ verifyTelemetry("insecure_opensearch_engine_count", 4, "insecure");
+ verifyTelemetry("secure_opensearch_update_count", 5, "securely updated");
+ verifyTelemetry("insecure_opensearch_update_count", 4, "insecurely updated");
+});
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..5d67739b22
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_opensearch_update.js
@@ -0,0 +1,120 @@
+/* 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";
+
+const KEYWORD = "keyword";
+let timerManager;
+
+add_task(async function setup() {
+ let server = useHttpServer("");
+ server.registerContentType("sjs", "sjs");
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+
+ timerManager = Cc["@mozilla.org/updates/timer-manager;1"].getService(
+ Ci.nsIUpdateTimerManager
+ );
+});
+
+add_task(async function test_installEngine_with_updates_disabled() {
+ const engineData = {
+ baseURL: gDataUrl,
+ name: "test engine",
+ method: "GET",
+ updateFile: "opensearch/simple.xml",
+ };
+
+ Services.prefs.setBoolPref(SearchUtils.BROWSER_SEARCH_PREF + "update", false);
+ Assert.ok(
+ !("search-engine-update-timer" in timerManager.wrappedJSObject._timers),
+ "Should not have registered the update timer already"
+ );
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}data/engineMaker.sjs?${JSON.stringify(engineData)}`,
+ });
+
+ Assert.ok(
+ Services.search.getEngineByName("test engine"),
+ "Should have added the test engine."
+ );
+ Assert.ok(
+ !("search-engine-update-timer" in timerManager.wrappedJSObject._timers),
+ "Should have not registered the update timer when updates are disabled"
+ );
+});
+
+add_task(async function test_installEngine_with_updates_enabled() {
+ const engineData = {
+ baseURL: gDataUrl,
+ name: "original engine",
+ method: "GET",
+ updateFile: "opensearch/simple.xml",
+ };
+
+ Services.prefs.setBoolPref(SearchUtils.BROWSER_SEARCH_PREF + "update", true);
+
+ Assert.ok(
+ !("search-engine-update-timer" in timerManager.wrappedJSObject._timers),
+ "Should not have registered the update timer already"
+ );
+
+ let engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}data/engineMaker.sjs?${JSON.stringify(engineData)}`,
+ });
+
+ Assert.ok(
+ "search-engine-update-timer" in timerManager.wrappedJSObject._timers,
+ "Should have registered the update timer"
+ );
+
+ engine.alias = KEYWORD;
+ await Services.search.moveEngine(engine, 0);
+
+ Assert.ok(
+ !!Services.search.getEngineByName("original engine"),
+ "Should be able to get the engine by the original name"
+ );
+ Assert.ok(
+ !Services.search.getEngineByName("simple"),
+ "Should not be able to get the engine by the new name"
+ );
+});
+
+add_task(async function test_engineUpdate() {
+ const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
+
+ let promiseUpdate = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ // set last update to 8 days ago, since the default interval is 7, then
+ // trigger an update
+ let engine = Services.search.getEngineByName("original engine");
+ engine.wrappedJSObject.setAttr("updateexpir", Date.now() - ONE_DAY_IN_MS * 8);
+ Services.search.QueryInterface(Ci.nsITimerCallback).notify(null);
+
+ await promiseUpdate;
+
+ Assert.equal(engine.name, "simple", "Should have updated the engine's name");
+
+ Assert.equal(engine.alias, KEYWORD, "Should have kept the keyword");
+ Assert.equal(
+ engine.wrappedJSObject.getAttr("order"),
+ 1,
+ "Should have kept the order"
+ );
+
+ Assert.ok(
+ !!Services.search.getEngineByName("simple"),
+ "Should be able to get the engine by the new name"
+ );
+ Assert.ok(
+ !Services.search.getEngineByName("original engine"),
+ "Should not be able to get the engine by the old name"
+ );
+});
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..24a2ecb8e6
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_override_allowlist.js
@@ -0,0 +1,391 @@
+/* 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";
+
+const allowlist = [
+ {
+ 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,
+ },
+ allowlistUrls: [
+ {
+ 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,
+ },
+ allowlistUrls: [
+ {
+ 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",
+ },
+ allowlistUrls: [
+ {
+ 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",
+ },
+ allowlistUrls: [
+ {
+ 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",
+ },
+ allowlistUrls: [
+ {
+ 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",
+ },
+ allowlistUrls: [
+ {
+ 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",
+ },
+ allowlistUrls: [
+ {
+ 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",
+ },
+ allowlistUrls: [
+ {
+ 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",
+ },
+ allowlistUrls: [
+ {
+ 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: {
+ browser_specific_settings: {
+ 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([
+ { ...allowlist[0], urls: test.allowlistUrls },
+ ]);
+ }
+
+ 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 = Services.search.getDefaultEngineInfo();
+ Assert.deepEqual(
+ engineInfo,
+ {
+ defaultSearchEngine: "simple-addon",
+ defaultSearchEngineData: {
+ loadPath: "[addon]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..bee90dbb5b
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_parseSubmissionURL.js
@@ -0,0 +1,182 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://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() {
+ let engine1 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine.xml`,
+ });
+ let engine2 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine-fr.xml`,
+ });
+
+ await SearchTestUtils.installSearchExtension({
+ name: "bacon_addParam",
+ keyword: "bacon_addParam",
+ encoding: "windows-1252",
+ search_url: "https://www.bacon.test/find",
+ });
+ await SearchTestUtils.installSearchExtension({
+ name: "idn_addParam",
+ keyword: "idn_addParam",
+ search_url: "https://www.xn--bcher-kva.ch/search",
+ });
+ let engine3 = Services.search.getEngineByName("bacon_addParam");
+ let engine4 = Services.search.getEngineByName("idn_addParam");
+
+ // The following engine provides it's query keyword in
+ // its template in the form of q={searchTerms}
+ let engine5 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine2.xml`,
+ });
+
+ // The following engines cannot identify the search parameter.
+ await SearchTestUtils.installSearchExtension({
+ name: "bacon",
+ keyword: "bacon",
+ search_url: "https://www.bacon.moz/search?q=",
+ search_url_get_params: "",
+ });
+
+ await Services.search.setDefault(
+ engine1,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ // Hide the default engines to prevent them from being used in the search.
+ for (let engine of await Services.search.getAppProvidedEngines()) {
+ await Services.search.removeEngine(engine);
+ }
+
+ // Test the first engine, whose URLs use UTF-8 encoding.
+ // This also tests the query parameter in a different position not being the
+ // first parameter.
+ let url = "https://www.google.com/search?foo=bar&q=caff%C3%A8";
+ let result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine.wrappedJSObject, engine1);
+ Assert.equal(result.terms, "caff\u00E8");
+
+ // 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 = "https://www.google.fr/search?q=caff%E8";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine.wrappedJSObject, engine2);
+ Assert.equal(result.terms, "caff\u00E8");
+
+ // 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 = "https://www.google.co.uk/search?q=caff%C3%A8";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine.wrappedJSObject, engine1);
+ Assert.equal(result.terms, "caff\u00E8");
+
+ // We support parsing URLs from a dynamically added engine.
+ url = "https://www.bacon.test/find?q=caff%E8";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine, engine3);
+ Assert.equal(result.terms, "caff\u00E8");
+
+ // Test URLs with unescaped unicode characters.
+ url = "https://www.google.com/search?q=foo+b\u00E4r";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine.wrappedJSObject, engine1);
+ Assert.equal(result.terms, "foo b\u00E4r");
+
+ // Test search engines with unescaped IDNs.
+ url = "https://www.b\u00FCcher.ch/search?q=foo+bar";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine, engine4);
+ Assert.equal(result.terms, "foo bar");
+
+ // Test search engines with escaped IDNs.
+ url = "https://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");
+
+ // Parsing of parameters from an engine template URL is not supported
+ // if no matching parameter value template is provided.
+ Assert.equal(
+ Services.search.parseSubmissionURL("https://www.bacon.moz/search?q=")
+ .engine,
+ null
+ );
+
+ // Parsing of parameters from an engine template URL is supported
+ // if a matching parameter value template is provided.
+ url = "https://duckduckgo.com/?foo=bar&q=caff%C3%A8";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine.wrappedJSObject, engine5);
+ Assert.equal(result.terms, "caff\u00E8");
+
+ // If the search params are in the template, the query parameter
+ // doesn't need to be separated from the host by a slash, only by
+ // by a question mark.
+ url = "https://duckduckgo.com?foo=bar&q=caff%C3%A8";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine.wrappedJSObject, engine5);
+ Assert.equal(result.terms, "caff\u00E8");
+
+ // 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.wrappedJSObject, engine1);
+ Assert.equal(result.terms, "caff\u00E8");
+
+ // Decoding search terms with multiple spaces should work.
+ result = Services.search.parseSubmissionURL(
+ "https://www.google.com/search?q=+with++spaces+"
+ );
+ Assert.equal(result.engine.wrappedJSObject, engine1);
+ Assert.equal(result.terms, " with spaces ");
+
+ // Parsing search terms with ampersands should work.
+ result = Services.search.parseSubmissionURL(
+ "https://www.google.com/search?q=with%26ampersand"
+ );
+ Assert.equal(result.engine.wrappedJSObject, engine1);
+ Assert.equal(result.terms, "with&ampersand");
+
+ // Capitals in the path should work
+ result = Services.search.parseSubmissionURL(
+ "https://www.google.com/SEARCH?q=caps"
+ );
+ Assert.equal(result.engine.wrappedJSObject, engine1);
+ Assert.equal(result.terms, "caps");
+
+ // An empty query parameter should work the same.
+ url = "https://www.google.com/search?q=";
+ result = Services.search.parseSubmissionURL(url);
+ Assert.equal(result.engine.wrappedJSObject, engine1);
+ Assert.equal(result.terms, "");
+
+ // There should be no match when the path is different.
+ result = Services.search.parseSubmissionURL(
+ "https://www.google.com/search/?q=test"
+ );
+ Assert.equal(result.engine, null);
+ Assert.equal(result.terms, "");
+
+ // There should be no match when the argument is different.
+ result = Services.search.parseSubmissionURL(
+ "https://www.google.com/search?q2=test"
+ );
+ Assert.equal(result.engine, null);
+ Assert.equal(result.terms, "");
+
+ // 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, "");
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_policyEngine.js b/toolkit/components/search/tests/xpcshell/test_policyEngine.js
new file mode 100644
index 0000000000..4dd58c8fc7
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_policyEngine.js
@@ -0,0 +1,185 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests that Enterprise Policy Engines can be installed correctly.
+ */
+
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+SearchSettings.SETTINGS_INVALIDATION_DELAY = 100;
+
+/**
+ * Loads a new enterprise policy, and re-initialise the search service
+ * with the new policy. Also waits for the search service to write the settings
+ * file to disk.
+ *
+ * @param {object} policy
+ * The enterprise policy to use.
+ */
+async function setupPolicyEngineWithJson(policy) {
+ Services.search.wrappedJSObject.reset();
+
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(policy);
+
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await Services.search.init();
+ await settingsWritten;
+}
+
+add_task(async function setup() {
+ // This initializes the policy engine for xpcshell tests
+ let policies = Cc["@mozilla.org/enterprisepolicies;1"].getService(
+ Ci.nsIObserver
+ );
+ policies.observe(null, "policies-startup", null);
+
+ Services.fog.initializeFOG();
+ await AddonTestUtils.promiseStartupManager();
+ await SearchTestUtils.useTestEngines();
+
+ SearchUtils.GENERAL_SEARCH_ENGINE_IDS = new Set([
+ "engine-resourceicon@search.mozilla.org",
+ ]);
+});
+
+add_task(async function test_enterprise_policy_engine() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "policy",
+ Description: "Test policy engine",
+ IconURL: "data:image/gif;base64,R0lGODl",
+ Alias: "p",
+ URLTemplate: "https://example.com?q={searchTerms}",
+ SuggestURLTemplate: "https://example.com/suggest/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+
+ let engine = Services.search.getEngineByName("policy");
+ Assert.ok(engine, "Should have installed the engine.");
+
+ Assert.equal(engine.name, "policy", "Should have the correct name");
+ Assert.equal(
+ engine.description,
+ "Test policy engine",
+ "Should have a description"
+ );
+ Assert.deepEqual(engine.aliases, ["p"], "Should have the correct alias");
+
+ let submission = engine.getSubmission("foo");
+ Assert.equal(
+ submission.uri.spec,
+ "https://example.com/?q=foo",
+ "Should have the correct search url"
+ );
+
+ submission = engine.getSubmission("foo", SearchUtils.URL_TYPE.SUGGEST_JSON);
+ Assert.equal(
+ submission.uri.spec,
+ "https://example.com/suggest/?q=foo",
+ "Should have the correct suggest url"
+ );
+
+ Services.search.defaultEngine = engine;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "other-policy",
+ displayName: "policy",
+ loadPath: "[policy]",
+ submissionUrl: "blank:",
+ verified: "verified",
+ },
+ });
+});
+
+add_task(async function test_enterprise_policy_engine_hidden_persisted() {
+ // Set the engine alias, and wait for the settings to be written.
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ let engine = Services.search.getEngineByName("policy");
+ engine.hidden = "p1";
+ engine.alias = "p1";
+ await settingsWritten;
+
+ // This will reset and re-initialise the search service.
+ await setupPolicyEngineWithJson({
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "policy",
+ Description: "Test policy engine",
+ IconURL: "data:image/gif;base64,R0lGODl",
+ Alias: "p",
+ URLTemplate: "https://example.com?q={searchTerms}",
+ SuggestURLTemplate: "https://example.com/suggest/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+ });
+
+ engine = Services.search.getEngineByName("policy");
+ Assert.equal(engine.alias, "p1", "Should have retained the engine alias");
+ Assert.ok(engine.hidden, "Should have kept the engine hidden");
+});
+
+add_task(async function test_enterprise_policy_engine_remove() {
+ // This will reset and re-initialise the search service.
+ await setupPolicyEngineWithJson({
+ policies: {},
+ });
+
+ Assert.ok(
+ !Services.search.getEngineByName("policy"),
+ "Should not have the policy engine installed"
+ );
+
+ let settings = await promiseSettingsData();
+ Assert.ok(
+ !settings.engines.find(e => e.name == "p1"),
+ "Should not have the engine settings stored"
+ );
+});
+
+add_task(async function test_enterprise_policy_hidden_default() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ SearchEngines: {
+ Remove: ["Test search engine"],
+ },
+ },
+ });
+
+ Services.search.resetToAppDefaultEngine();
+
+ Assert.equal(Services.search.defaultEngine.name, "engine-resourceicon");
+});
+
+add_task(async function test_enterprise_policy_default() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ SearchEngines: {
+ Default: "engine-pref",
+ },
+ },
+ });
+
+ Services.search.resetToAppDefaultEngine();
+
+ Assert.equal(Services.search.defaultEngine.name, "engine-pref");
+});
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_reload_engines.js b/toolkit/components/search/tests/xpcshell/test_reload_engines.js
new file mode 100644
index 0000000000..4d3a3d3659
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_reload_engines.js
@@ -0,0 +1,316 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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"],
+ },
+ },
+ ],
+ },
+];
+
+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.getMetaDataAttribute(
+ "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..ded610ca56
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_reload_engines_experiment.js
@@ -0,0 +1,133 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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",
+ },
+ ],
+ },
+];
+
+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.getMetaDataAttribute(
+ "useSavedOrder"
+ ),
+ false,
+ "Should not have set the useSavedOrder preference"
+ );
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_reload_engines_locales.js b/toolkit/components/search/tests/xpcshell/test_reload_engines_locales.js
new file mode 100644
index 0000000000..9e98e5e73a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_reload_engines_locales.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests reloading engines when changing the in-use locale of a WebExtension,
+ * where the name of the engine changes as well.
+ */
+
+"use strict";
+
+const CONFIG = [
+ {
+ webExtension: {
+ id: "engine@search.mozilla.org",
+ },
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ default: "yes",
+ },
+ ],
+ },
+ {
+ webExtension: {
+ id: "engine-diff-name@search.mozilla.org",
+ },
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ excluded: { locales: { matches: ["gd"] } },
+ },
+ {
+ included: { locales: { matches: ["gd"] } },
+ webExtension: {
+ locales: ["gd"],
+ },
+ },
+ ],
+ },
+];
+
+add_setup(async () => {
+ Services.locale.availableLocales = [
+ ...Services.locale.availableLocales,
+ "en",
+ "gd",
+ ];
+ Services.locale.requestedLocales = ["gd"];
+
+ await SearchTestUtils.useTestEngines("data", null, CONFIG);
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+});
+
+add_task(async function test_config_updated_engine_changes() {
+ let engines = await Services.search.getEngines();
+ Assert.deepEqual(
+ engines.map(e => e.name),
+ ["Test search engine", "engine-diff-name-gd"],
+ "Should have the correct engines installed"
+ );
+
+ let engine = await Services.search.getEngineByName("engine-diff-name-gd");
+ Assert.equal(
+ engine.name,
+ "engine-diff-name-gd",
+ "Should have the correct engine name"
+ );
+ Assert.equal(
+ engine.getSubmission("test").uri.spec,
+ "https://gd.wikipedia.com/search",
+ "Should have the gd search url"
+ );
+
+ await promiseSetLocale("en");
+
+ engines = await Services.search.getEngines();
+ Assert.deepEqual(
+ engines.map(e => e.name),
+ ["Test search engine", "engine-diff-name-en"],
+ "Should have the correct engines installed after locale change"
+ );
+
+ engine = await Services.search.getEngineByName("engine-diff-name-en");
+ Assert.equal(
+ engine.name,
+ "engine-diff-name-en",
+ "Should have the correct engine name"
+ );
+ Assert.equal(
+ engine.getSubmission("test").uri.spec,
+ "https://en.wikipedia.com/search",
+ "Should have the en search url"
+ );
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_remove_engine_notification_box.js b/toolkit/components/search/tests/xpcshell/test_remove_engine_notification_box.js
new file mode 100644
index 0000000000..867412a771
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_remove_engine_notification_box.js
@@ -0,0 +1,392 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.importGlobalProperties(["structuredClone"]);
+
+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",
+ },
+ {
+ included: { regions: ["FR"] },
+ default: "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",
+ },
+ ],
+ },
+];
+
+const CONFIG_UPDATED = [
+ {
+ webExtension: {
+ id: "engine-pref@search.mozilla.org",
+ },
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ },
+ {
+ included: { regions: ["FR"] },
+ default: "yes",
+ },
+ ],
+ },
+];
+
+let stub;
+let settingsFilePath;
+let userSettings;
+
+add_task(async function setup() {
+ SearchSettings.SETTINGS_INVALIDATION_DELAY = 100;
+ SearchTestUtils.useMockIdleService();
+ await SearchTestUtils.useTestEngines("data", null, CONFIG);
+ await AddonTestUtils.promiseStartupManager();
+
+ stub = sinon.stub(
+ await Services.search.wrappedJSObject,
+ "_showRemovalOfSearchEngineNotificationBox"
+ );
+
+ settingsFilePath = PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME);
+
+ Region._setHomeRegion("", false);
+
+ let promiseSaved = promiseAfterSettings();
+ await Services.search.init();
+ await promiseSaved;
+
+ userSettings = await Services.search.wrappedJSObject._settings.get();
+});
+
+// Verify the loaded configuration matches what we expect for the test.
+add_task(async function test_initial_config_correct() {
+ const installedEngines = await Services.search.getAppProvidedEngines();
+ Assert.deepEqual(
+ installedEngines.map(e => e.identifier),
+ ["engine", "engine-pref"],
+ "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_metadata_undefined() {
+ let defaultEngineChanged = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ info("Update region to FR.");
+ Region._setHomeRegion("FR", false);
+
+ let settings = structuredClone(userSettings);
+ settings.metaData = undefined;
+ await reloadEngines(settings);
+ Assert.ok(
+ stub.notCalled,
+ "_reloadEngines should not have shown the notification box."
+ );
+
+ settings = structuredClone(userSettings);
+ settings.metaData = undefined;
+ await loadEngines(settings);
+ Assert.ok(
+ stub.notCalled,
+ "_loadEngines should not have shown the notification box."
+ );
+
+ const newDefault = await defaultEngineChanged;
+ Assert.equal(
+ newDefault.QueryInterface(Ci.nsISearchEngine).name,
+ "engine-pref",
+ "Should have correctly notified the new default engine."
+ );
+});
+
+add_task(async function test_metadata_changed() {
+ let metaDataProperties = [
+ "locale",
+ "region",
+ "channel",
+ "experiment",
+ "distroID",
+ ];
+
+ for (let name of metaDataProperties) {
+ let settings = structuredClone(userSettings);
+ settings.metaData[name] = "test";
+ await assert_metadata_changed(settings);
+ }
+});
+
+add_task(async function test_default_engine_unchanged() {
+ let currentEngineName =
+ Services.search.wrappedJSObject._getEngineDefault(false).name;
+
+ Assert.equal(
+ currentEngineName,
+ "Test search engine",
+ "Default engine should be unchanged."
+ );
+
+ await reloadEngines(structuredClone(userSettings));
+ Assert.ok(
+ stub.notCalled,
+ "_reloadEngines should not have shown the notification box."
+ );
+
+ await loadEngines(structuredClone(userSettings));
+ Assert.ok(
+ stub.notCalled,
+ "_loadEngines should not have shown the notification box."
+ );
+});
+
+add_task(async function test_new_current_engine_is_undefined() {
+ consoleAllowList.push("No default engine");
+ let settings = structuredClone(userSettings);
+ let getEngineDefaultStub = sinon.stub(
+ await Services.search.wrappedJSObject,
+ "_getEngineDefault"
+ );
+ getEngineDefaultStub.returns(undefined);
+
+ await loadEngines(settings);
+ Assert.ok(
+ stub.notCalled,
+ "_loadEngines should not have shown the notification box."
+ );
+
+ getEngineDefaultStub.restore();
+});
+
+add_task(async function test_current_engine_is_null() {
+ Services.search.wrappedJSObject._currentEngine = null;
+
+ await reloadEngines(structuredClone(userSettings));
+ Assert.ok(
+ stub.notCalled,
+ "_reloadEngines should not have shown the notification box."
+ );
+
+ let settings = structuredClone(userSettings);
+ settings.metaData.current = null;
+ await loadEngines(settings);
+ Assert.ok(
+ stub.notCalled,
+ "_loadEngines should not have shown the notification box."
+ );
+});
+
+add_task(async function test_default_changed_and_metadata_unchanged_exists() {
+ info("Update region to FR to change engine.");
+ Region._setHomeRegion("FR", false);
+
+ info("Set user settings metadata to the same properties as cached metadata.");
+ await Services.search.wrappedJSObject._fetchEngineSelectorEngines();
+ userSettings.metaData = {
+ ...Services.search.wrappedJSObject._settings.getSettingsMetaData(),
+ appDefaultEngine: "Test search engine",
+ };
+
+ await reloadEngines(structuredClone(userSettings));
+ Assert.ok(
+ stub.notCalled,
+ "_reloadEngines should not show the notification box as the engine still exists."
+ );
+
+ // Reset.
+ Region._setHomeRegion("US", false);
+ await reloadEngines(structuredClone(userSettings));
+});
+
+add_task(async function test_default_engine_changed_and_metadata_unchanged() {
+ info("Update region to FR to change engine.");
+ Region._setHomeRegion("FR", false);
+
+ const defaultEngineChanged = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ info("Set user settings metadata to the same properties as cached metadata.");
+ await Services.search.wrappedJSObject._fetchEngineSelectorEngines();
+ userSettings.metaData = {
+ ...Services.search.wrappedJSObject._settings.getSettingsMetaData(),
+ appDefaultEngineId: "engine@search.mozilla.orgdefault",
+ };
+
+ // Update config by removing the app default engine
+ await setConfigToLoad(CONFIG_UPDATED);
+
+ await reloadEngines(structuredClone(userSettings));
+ Assert.ok(
+ stub.calledOnce,
+ "_reloadEngines should show the notification box."
+ );
+
+ Assert.deepEqual(
+ stub.firstCall.args,
+ ["Test search engine", "engine-pref"],
+ "_showRemovalOfSearchEngineNotificationBox should display " +
+ "'Test search engine' as the engine removed and 'engine-pref' as the new " +
+ "default engine."
+ );
+
+ const newDefault = await defaultEngineChanged;
+ Assert.equal(
+ newDefault.QueryInterface(Ci.nsISearchEngine).name,
+ "engine-pref",
+ "Should have correctly notified the new default engine"
+ );
+
+ info("Reset userSettings.metaData.current engine.");
+ let settings = structuredClone(userSettings);
+ settings.metaData.current = Services.search.wrappedJSObject._currentEngine;
+
+ await loadEngines(settings);
+ Assert.ok(stub.calledTwice, "_loadEngines should show the notification box.");
+
+ Assert.deepEqual(
+ stub.secondCall.args,
+ ["Test search engine", "engine-pref"],
+ "_showRemovalOfSearchEngineNotificationBox should display " +
+ "'Test search engine' as the engine removed and 'engine-pref' as the new " +
+ "default engine."
+ );
+});
+
+add_task(async function test_app_default_engine_changed_on_start_up() {
+ let settings = structuredClone(userSettings);
+
+ // Set the current engine to "" so we can use the app default engine as
+ // default
+ settings.metaData.current = "";
+
+ // Update config by removing the app default engine
+ await setConfigToLoad(CONFIG_UPDATED);
+
+ await loadEngines(settings);
+ Assert.ok(
+ stub.calledThrice,
+ "_loadEngines should show the notification box."
+ );
+});
+
+add_task(async function test_app_default_engine_change_start_up_still_exists() {
+ stub.resetHistory();
+ let settings = structuredClone(userSettings);
+
+ // Set the current engine to "" so we can use the app default engine as
+ // default
+ settings.metaData.current = "";
+ settings.metaData.appDefaultEngine = "Test search engine";
+
+ await setConfigToLoad(CONFIG);
+
+ await loadEngines(settings);
+ Assert.ok(
+ stub.notCalled,
+ "_loadEngines should not show the notification box."
+ );
+});
+
+async function setConfigToLoad(config) {
+ let searchSettingsObj = await RemoteSettings(SearchUtils.SETTINGS_KEY);
+ // Restore the get method in order to stub it again in useTestEngines
+ searchSettingsObj.get.restore();
+ Services.search.wrappedJSObject.resetEngineSelector();
+ await SearchTestUtils.useTestEngines("data", null, config);
+}
+
+function writeSettings(settings) {
+ return IOUtils.writeJSON(settingsFilePath, settings, { compress: true });
+}
+
+async function reloadEngines(settings) {
+ let promiseSaved = promiseAfterSettings();
+
+ await Services.search.wrappedJSObject._reloadEngines(settings);
+
+ await promiseSaved;
+}
+
+async function loadEngines(settings) {
+ await writeSettings(settings);
+
+ let promiseSaved = promiseAfterSettings();
+
+ Services.search.wrappedJSObject.reset();
+ await Services.search.init();
+
+ await promiseSaved;
+}
+
+async function assert_metadata_changed(settings) {
+ info("Update region.");
+ Region._setHomeRegion("FR", false);
+ await reloadEngines(settings);
+ Region._setHomeRegion("", false);
+
+ let defaultEngineChanged = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.DEFAULT,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ await reloadEngines(settings);
+ Assert.ok(
+ stub.notCalled,
+ "_reloadEngines should not have shown the notification box."
+ );
+
+ let newDefault = await defaultEngineChanged;
+ Assert.equal(
+ newDefault.QueryInterface(Ci.nsISearchEngine).name,
+ "Test search engine",
+ "Should have correctly notified the new default engine."
+ );
+
+ Region._setHomeRegion("FR", false);
+ await reloadEngines(settings);
+ Region._setHomeRegion("", false);
+
+ await loadEngines(settings);
+ Assert.ok(
+ stub.notCalled,
+ "_loadEngines should not have shown the notification box."
+ );
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "Test search engine",
+ "Should have correctly notified the new default engine."
+ );
+}
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_save_sorted_engines.js b/toolkit/components/search/tests/xpcshell/test_save_sorted_engines.js
new file mode 100644
index 0000000000..b609960d1f
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_save_sorted_engines.js
@@ -0,0 +1,67 @@
+/* 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 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine.xml`,
+ });
+ let engine2 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}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
+ await SearchTestUtils.installSearchExtension({
+ name: "foo",
+ keyword: "foo",
+ });
+
+ let engine = Services.search.getEngineByName("foo");
+ 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..161e7d6c63
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest.js
@@ -0,0 +1,891 @@
+/* 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 { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { FormHistory } = ChromeUtils.importESModule(
+ "resource://gre/modules/FormHistory.sys.mjs"
+);
+const { SearchSuggestionController } = ChromeUtils.importESModule(
+ "resource://gre/modules/SearchSuggestionController.sys.mjs"
+);
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const ENGINE_NAME = "other";
+const SEARCH_TELEMETRY_LATENCY = "SEARCH_SUGGESTIONS_LATENCY_MS";
+
+// 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);
+ // These tests intentionally test broken connections.
+ consoleAllowList = consoleAllowList.concat([
+ "Non-200 status or empty HTTP response: 404",
+ "Non-200 status or empty HTTP response: 500",
+ "Unexpected response, searchString does not match remote response",
+ "HTTP request timeout",
+ "HTTP error",
+ ]);
+
+ 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 = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engineMaker.sjs?${JSON.stringify(getEngineData)}`,
+ });
+ postEngine = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engineMaker.sjs?${JSON.stringify(postEngineData)}`,
+ });
+ unresolvableEngine = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engineMaker.sjs?${JSON.stringify(unresolvableEngineData)}`,
+ });
+ alternateJSONEngine = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engineMaker.sjs?${JSON.stringify(
+ alternateJSONSuggestEngineData
+ )}`,
+ });
+});
+
+// Begin tests
+
+add_task(async function simple_no_result_promise() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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);
+
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function simple_remote_no_local_result() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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");
+
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function simple_remote_no_local_result_telemetry() {
+ Services.telemetry.clearScalars();
+
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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-${ENGINE_NAME}` in scalar, "correct telemetry category");
+ Assert.notEqual(scalar[`sggt-${ENGINE_NAME}`], 0, "bandwidth logged");
+
+ assertLatencyHistogram(histogram, true);
+});
+
+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() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ // 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 ");
+
+ // Only the second fetch's latency should be recorded since the first fetch
+ // was aborted and latencies for aborted fetches are not recorded.
+ assertLatencyHistogram(histogram, true);
+});
+
+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 histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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);
+
+ assertLatencyHistogram(histogram, false);
+});
+
+add_task(async function someremote_maxLocal() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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)
+ );
+ }
+
+ assertLatencyHistogram(histogram, true);
+});
+
+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() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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);
+ assertLatencyHistogram(histogram, false);
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", true);
+});
+
+add_task(
+ async function local_result_returned_remote_result_disabled_after_creation_of_controller() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+ 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);
+ assertLatencyHistogram(histogram, false);
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", true);
+ }
+);
+
+add_task(
+ async function one_of_each_disabled_before_creation_enabled_after_creation_of_controller() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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");
+
+ assertLatencyHistogram(histogram, true);
+
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", true);
+ }
+);
+
+add_task(async function one_local_zero_remote() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+ 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);
+ assertLatencyHistogram(histogram, false);
+});
+
+add_task(async function zero_local_one_remote() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+ 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");
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function stop_search() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+ 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);
+ });
+ assertLatencyHistogram(histogram, false);
+});
+
+add_task(async function empty_searchTerm() {
+ // Empty searches don't go to the server but still get form history.
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+ 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);
+ assertLatencyHistogram(histogram, false);
+});
+
+add_task(async function slow_timeout() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ // Make the server return suggestions on a delay longer than the timeout of
+ // the suggestion controller.
+ let delayMs = 3 * SearchSuggestionController.REMOTE_TIMEOUT_DEFAULT;
+ let searchString = `delay${delayMs} `;
+
+ // Add a local result.
+ let localValue = searchString + " local result";
+ await updateSearchHistory("bump", localValue);
+
+ // Do a search. The remote fetch should time out but the local result should
+ // be returned.
+ let controller = new SearchSuggestionController();
+ let result = await controller.fetch(searchString, false, getEngine);
+ Assert.equal(result.term, searchString);
+ Assert.equal(result.local.length, 1);
+ Assert.equal(result.local[0].value, localValue);
+ Assert.equal(result.remote.length, 0);
+
+ // The remote fetch isn't done yet, so the latency histogram should not be
+ // updated.
+ assertLatencyHistogram(histogram, false);
+
+ // Wait for the remote fetch to finish.
+ await new Promise(r => setTimeout(r, delayMs));
+
+ // Now the latency histogram should be updated.
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function slow_timeout_2() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ // Make the server return suggestions on a delay longer the timeout of the
+ // suggestion controller.
+ let delayMs = 3 * SearchSuggestionController.REMOTE_TIMEOUT_DEFAULT;
+ let searchString = `delay${delayMs} `;
+
+ // Add a local result.
+ let localValue = searchString + " local result";
+ await updateSearchHistory("bump", localValue);
+
+ // Do two searches using the same controller. Both times, the remote fetches
+ // should time out and only the local result should be returned. The second
+ // search should abort the remote fetch of the first search, and the remote
+ // fetch of the second search should be ongoing when the second search
+ // finishes.
+ let controller = new SearchSuggestionController();
+ for (let i = 0; i < 2; i++) {
+ let result = await controller.fetch(searchString, false, getEngine);
+ Assert.equal(result.term, searchString);
+ Assert.equal(result.local.length, 1);
+ Assert.equal(result.local[0].value, localValue);
+ Assert.equal(result.remote.length, 0);
+ }
+
+ // The remote fetch of the second search isn't done yet, so the latency
+ // histogram should not be updated.
+ assertLatencyHistogram(histogram, false);
+
+ // Wait for the second remote fetch to finish.
+ await new Promise(r => setTimeout(r, delayMs));
+
+ // Now the latency histogram should be updated, and only the remote fetch of
+ // the second search should be recorded.
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function slow_stop() {
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ // Make the server return suggestions on a delay longer the timeout of the
+ // suggestion controller.
+ let delayMs = 3 * SearchSuggestionController.REMOTE_TIMEOUT_DEFAULT;
+ let searchString = `delay${delayMs} `;
+
+ // Do a search but stop it before it finishes. Wait a tick before stopping it
+ // to better simulate the real world.
+ let controller = new SearchSuggestionController();
+ let resultPromise = controller.fetch(searchString, false, getEngine);
+ await TestUtils.waitForTick();
+ controller.stop();
+ let result = await resultPromise;
+ Assert.equal(result, null, "No result should be returned");
+
+ // The remote fetch should have been aborted by stopping the controller, but
+ // wait for the timeout period just to make sure it's done.
+ await new Promise(r => setTimeout(r, delayMs));
+
+ // Since the latencies of aborted fetches are not recorded, the latency
+ // histogram should not be updated.
+ assertLatencyHistogram(histogram, false);
+});
+
+// Error handling
+
+add_task(async function remote_term_mismatch() {
+ await updateSearchHistory("bump", "Query Mismatch Entry");
+
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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);
+
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function http_404() {
+ await updateSearchHistory("bump", "HTTP 404 Entry");
+
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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);
+
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function http_500() {
+ await updateSearchHistory("bump", "HTTP 500 Entry");
+
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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);
+
+ assertLatencyHistogram(histogram, true);
+});
+
+add_task(async function unresolvable_server() {
+ await updateSearchHistory("bump", "Unresolvable Server Entry");
+
+ let histogram = TelemetryTestUtils.getAndClearKeyedHistogram(
+ SEARCH_TELEMETRY_LATENCY
+ );
+
+ 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);
+
+ // This latency assert fails on Windows 7 (NT version 6.1), so skip it there.
+ if (!AppConstants.isPlatformAndVersionAtMost("win", "6.1")) {
+ assertLatencyHistogram(histogram, true);
+ }
+});
+
+// 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);
+});
+
+// Non-English characters
+
+add_task(async function suggestions_contain_escaped_unicode() {
+ let controller = new SearchSuggestionController();
+ let result = await controller.fetch("stü", false, getEngine);
+ Assert.equal(result.term, "stü");
+ Assert.equal(result.local.length, 0);
+ Assert.equal(result.remote.length, 2);
+ Assert.equal(result.remote[0].value, "stühle");
+ Assert.equal(result.remote[1].value, "stüssy");
+});
+
+// Helpers
+
+function updateSearchHistory(operation, value) {
+ return FormHistory.update({
+ op: operation,
+ fieldname: "searchbar-history",
+ value,
+ });
+}
+
+function assertLatencyHistogram(histogram, shouldRecord) {
+ let snapshot = histogram.snapshot();
+ info("Checking latency snapshot: " + JSON.stringify(snapshot));
+
+ // Build a map from engine ID => number of non-zero values recorded for it.
+ let valueCountByEngineId = Object.entries(snapshot).reduce(
+ (memo, [key, data]) => {
+ memo[key] = Object.values(data.values).filter(v => v != 0);
+ return memo;
+ },
+ {}
+ );
+
+ let expected = shouldRecord ? { [ENGINE_NAME]: [1] } : {};
+ Assert.deepEqual(
+ valueCountByEngineId,
+ expected,
+ shouldRecord ? "Latency histogram updated" : "Latency histogram not updated"
+ );
+}
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..78d4fde8f1
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest_cookies.js
@@ -0,0 +1,143 @@
+/* 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.importESModule(
+ "resource://gre/modules/SearchSuggestionController.sys.mjs"
+);
+
+// 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
+ );
+ 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;
+}
+
+let engines;
+
+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("");
+ engines = [
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engineMaker.sjs?${JSON.stringify({
+ baseURL: gDataUrl,
+ name: unicodeName,
+ method: "GET",
+ })}`,
+ }),
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}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");
+});
+
+add_task(async function test_private_mode() {
+ await test_engine(true);
+});
+add_task(async function test_normal_mode() {
+ await test_engine(false);
+});
+
+async function test_engine(privateMode) {
+ info(`Testing ${privateMode ? "private" : "normal"} mode`);
+ let controller = new SearchSuggestionController();
+ let result = await controller.fetch("no results", privateMode, engines[0]);
+ Assert.equal(result.local.length, 0, "Should have no local suggestions");
+ Assert.equal(result.remote.length, 0, "Should have no remote suggestions");
+
+ result = await controller.fetch("cookie", privateMode, engines[1]);
+ Assert.equal(result.local.length, 0, "Should have no local suggestions");
+ Assert.equal(result.remote.length, 0, "Should have no remote suggestions");
+ 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_extraParams.js b/toolkit/components/search/tests/xpcshell/test_searchSuggest_extraParams.js
new file mode 100644
index 0000000000..8bac7d39cb
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest_extraParams.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_CONFIG = [
+ {
+ webExtension: { id: "get@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ suggestExtraParams: [
+ {
+ name: "custom_param",
+ pref: "test_pref_param",
+ condition: "pref",
+ },
+ ],
+ },
+];
+
+add_task(async function setup() {
+ await SearchTestUtils.useTestEngines("method-extensions", null, TEST_CONFIG);
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+});
+
+add_task(async function test_custom_suggest_param() {
+ let engine = Services.search.getEngineByName("Get Engine");
+ Assert.notEqual(engine, null, "Should have found an engine");
+
+ let submissionSuggest = engine.getSubmission(
+ "bar",
+ SearchUtils.URL_TYPE.SUGGEST_JSON
+ );
+ Assert.equal(
+ submissionSuggest.uri.spec,
+ "https://example.com/?webExtension=1&suggest=bar",
+ "Suggest URLs should match"
+ );
+
+ let defaultBranch = Services.prefs.getDefaultBranch("browser.search.");
+ defaultBranch.setCharPref("param.test_pref_param", "good");
+
+ let nextSubmissionSuggest = engine.getSubmission(
+ "bar",
+ SearchUtils.URL_TYPE.SUGGEST_JSON
+ );
+ Assert.equal(
+ nextSubmissionSuggest.uri.spec,
+ "https://example.com/?custom_param=good&webExtension=1&suggest=bar",
+ "Suggest URLs should include custom param"
+ );
+});
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..3337bf9a27
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_searchSuggest_private.js
@@ -0,0 +1,54 @@
+/* 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.importESModule(
+ "resource://gre/modules/SearchSuggestionController.sys.mjs"
+);
+
+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 SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}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_searchTermFromResult.js b/toolkit/components/search/tests/xpcshell/test_searchTermFromResult.js
new file mode 100644
index 0000000000..f2693efc3b
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_searchTermFromResult.js
@@ -0,0 +1,303 @@
+/* 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/. */
+
+/*
+ * Tests searchTermFromResult API.
+ */
+
+let defaultEngine;
+
+// The test string contains special characters to ensure
+// that they are encoded/decoded properly.
+const TERM = "c;,?:@&=+$-_.!~*'()# d\u00E8f";
+const TERM_ENCODED = "c%3B%2C%3F%3A%40%26%3D%2B%24-_.!~*'()%23+d%C3%A8f";
+
+add_task(async function setup() {
+ await SearchTestUtils.useTestEngines("data", null, [
+ {
+ webExtension: {
+ id: "engine-purposes@search.mozilla.org",
+ },
+ appliesTo: [
+ {
+ included: { everywhere: true },
+ default: "yes",
+ },
+ ],
+ },
+ ]);
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+
+ defaultEngine = Services.search.getEngineByName("Test Engine With Purposes");
+});
+
+add_task(async function test_searchTermFromResult_withAllPurposes() {
+ for (let purpose of Object.values(SearchUtils.PARAM_PURPOSES)) {
+ let uri = defaultEngine.getSubmission(TERM, null, purpose).uri;
+ let searchTerm = defaultEngine.searchTermFromResult(uri);
+ Assert.equal(
+ searchTerm,
+ TERM,
+ `Should return the correct url for purpose: ${purpose}`
+ );
+ }
+});
+
+add_task(async function test_searchTermFromResult() {
+ // Internationalized Domain Name search engine.
+ await SearchTestUtils.installSearchExtension({
+ name: "idn_addParam",
+ keyword: "idn_addParam",
+ search_url: "https://www.xn--bcher-kva.ch/search",
+ });
+ let engineEscapedIDN = Services.search.getEngineByName("idn_addParam");
+
+ // Setup server for french engine.
+ await useHttpServer();
+
+ // For ISO-8859-1 encoding testing.
+ let engineISOCharset = await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine-fr.xml`,
+ });
+
+ // For Windows-1252 encoding testing.
+ await SearchTestUtils.installSearchExtension({
+ name: "bacon_addParam",
+ keyword: "bacon_addParam",
+ encoding: "windows-1252",
+ search_url: "https://www.bacon.test/find",
+ });
+ let engineWinCharset = Services.search.getEngineByName("bacon_addParam");
+
+ // Verify getValidEngineUrl returns a URL that can return a search term.
+ let testUrl = getValidEngineUrl();
+ Assert.equal(
+ getTerm(testUrl),
+ TERM,
+ "Should get term from a url generated by getSubmission."
+ );
+
+ testUrl = getValidEngineUrl();
+ testUrl.pathname = "/SEARCH";
+ Assert.equal(
+ getTerm(testUrl),
+ TERM,
+ "Should get term even if path is not the same case as the engine."
+ );
+
+ let url = `https://www.xn--bcher-kva.ch/search?q=${TERM_ENCODED}`;
+ Assert.equal(
+ getTerm(url, engineEscapedIDN),
+ TERM,
+ "Should get term from IDNs urls."
+ );
+
+ url = `http://www.google.fr/search?q=caf%E8+au+lait&ie=iso-8859-1&oe=iso-8859-1`;
+ Assert.equal(
+ getTerm(url, engineISOCharset),
+ "caf\u00E8 au lait",
+ "Should get term from ISO-8859-1 encoded url containing a search term."
+ );
+
+ url = `http://www.google.fr/search?&ie=iso-8859-1&oe=iso-8859-1&q=`;
+ Assert.equal(
+ getTerm(url, engineISOCharset),
+ "",
+ "Should get a blank string from ISO-8859-1 encoded url missing a search term"
+ );
+
+ url = "https://www.bacon.test/find?q=caf%E8+au+lait";
+ Assert.equal(
+ getTerm(url, engineWinCharset),
+ "caf\u00E8 au lait",
+ "Should get term from Windows-1252 encoded url containing a search term."
+ );
+
+ url = "https://www.bacon.test/find?q=";
+ Assert.equal(
+ getTerm(url, engineWinCharset),
+ "",
+ "Should get a blank string from Windows-1252 encoded url missing a search term."
+ );
+
+ url = "about:blank";
+ Assert.equal(getTerm(url), "", "Should get a blank string from about:blank.");
+
+ url = "about:newtab";
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from about:newtab."
+ );
+});
+
+// Use a version of the url that should return a term and make minute
+// modifications that should cause it to return a blank value.
+add_task(async function test_searchTermFromResult_blank() {
+ let url = getValidEngineUrl();
+ url.searchParams.set("hello", "world");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from url containing query param name not recognized by the engine."
+ );
+
+ url = getValidEngineUrl();
+ url.protocol = "http";
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from url that has a different scheme from the engine."
+ );
+
+ url = getValidEngineUrl();
+ url.protocol = "http";
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from url that has a different path from the engine."
+ );
+
+ url = getValidEngineUrl();
+ url.host = "images.example.com";
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from url that has a different host from the engine."
+ );
+
+ url = getValidEngineUrl();
+ url.host = "example.com";
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from url that has a different host from the engine."
+ );
+
+ url = getValidEngineUrl();
+ url.searchParams.set("form", "MOZUNKNOWN");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from a url that has an un-recognized form value."
+ );
+
+ url = getValidEngineUrl();
+ url.searchParams.set("q", "");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from a url with a missing search query value."
+ );
+
+ url = getValidEngineUrl();
+ url.searchParams.delete("q");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from a url with a missing search query name."
+ );
+
+ url = getValidEngineUrl();
+ url.searchParams.delete("pc");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from a url with a missing a query parameter."
+ );
+
+ url = getValidEngineUrl();
+ url.searchParams.delete("form");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string from a url with a missing a query parameter."
+ );
+});
+
+add_task(async function test_searchTermFromResult_prefParam() {
+ const defaultBranch = Services.prefs.getDefaultBranch(
+ SearchUtils.BROWSER_SEARCH_PREF
+ );
+
+ defaultBranch.setCharPref("param.testChannelEnabled", "yes");
+
+ let url = getValidEngineUrl(true);
+ Assert.equal(getTerm(url), TERM, "Should get term after pref is turned on.");
+
+ url.searchParams.delete("channel");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string if pref is on and channel param is missing."
+ );
+
+ defaultBranch.setCharPref("param.testChannelEnabled", "");
+ url = getValidEngineUrl(true);
+ Assert.equal(getTerm(url), TERM, "Should get term after pref is turned off.");
+
+ url.searchParams.set("channel", "yes");
+ Assert.equal(
+ getTerm(url),
+ "",
+ "Should get a blank string if pref is turned off but channel param is present."
+ );
+});
+
+// searchTermFromResult attempts to look into the template of a search
+// engine if query params aren't present in the url.params, so make sure
+// it works properly and fails gracefully.
+add_task(async function test_searchTermFromResult_paramsInSearchUrl() {
+ await SearchTestUtils.installSearchExtension({
+ name: "engine_params_in_search_url",
+ search_url: "https://example.com/?q={searchTerms}&pc=firefox",
+ search_url_get_params: "",
+ });
+ let testEngine = Services.search.getEngineByName(
+ "engine_params_in_search_url"
+ );
+ let url = `https://example.com/?q=${TERM_ENCODED}&pc=firefox`;
+ Assert.equal(
+ getTerm(url, testEngine),
+ TERM,
+ "Should get term from an engine with params in its search url."
+ );
+
+ url = `https://example.com/?q=${TERM_ENCODED}`;
+ Assert.equal(
+ getTerm(url, testEngine),
+ "",
+ "Should get a blank string when not all params are present."
+ );
+
+ await SearchTestUtils.installSearchExtension({
+ name: "engine_params_in_search_url_without_delimiter",
+ search_url: "https://example.com/q={searchTerms}",
+ search_url_get_params: "",
+ });
+ testEngine = Services.search.getEngineByName(
+ "engine_params_in_search_url_without_delimiter"
+ );
+ url = `https://example.com/?q=${TERM_ENCODED}&pc=firefox&page=1`;
+ Assert.equal(
+ getTerm(url, testEngine),
+ "",
+ "Should get a blank string from an engine with no params and no delimiter in its url."
+ );
+});
+
+function getTerm(url, searchEngine = defaultEngine) {
+ return searchEngine.searchTermFromResult(Services.io.newURI(url.toString()));
+}
+
+// Return a new instance of a submission URL so that it can modified
+// and tested again. Allow callers to force the cache to update, especially
+// if the engine is expected to have updated.
+function getValidEngineUrl(updateCache = false) {
+ if (updateCache || !this._submissionUrl) {
+ this._submissionUrl = defaultEngine.getSubmission(TERM, null).uri.spec;
+ }
+ return new URL(this._submissionUrl);
+}
diff --git a/toolkit/components/search/tests/xpcshell/test_searchUrlDomain.js b/toolkit/components/search/tests/xpcshell/test_searchUrlDomain.js
new file mode 100644
index 0000000000..6cec77043a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_searchUrlDomain.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests searchUrlDomain 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.searchUrlDomain, "www.google.com");
+});
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..4a5d42211a
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_selectedEngine.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const kDefaultEngineName = "engine1";
+
+add_task(async function setup() {
+ useHttpServer();
+ await AddonTestUtils.promiseStartupManager();
+ await SearchTestUtils.useTestEngines("data1");
+ Assert.ok(!Services.search.isInitialized);
+ Services.prefs.setBoolPref(
+ "browser.search.removeEngineInfobar.enabled",
+ false
+ );
+});
+
+// Check that the default engine matches the defaultenginename pref
+add_task(async function test_defaultEngine() {
+ await Services.search.init();
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}engine.xml`,
+ });
+
+ 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),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ Assert.equal(Services.search.defaultEngine.name, kTestEngineName);
+ await promiseAfterSettings();
+
+ // Check that the a hash was saved.
+ let metadata = await promiseGlobalMetadata();
+ Assert.equal(metadata.defaultEngineIdHash.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.resetToAppDefaultEngine();
+ 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),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ Assert.equal(Services.search.defaultEngine.name, kTestEngineName);
+ await promiseAfterSettings();
+
+ // Then mess with the file (make the hash invalid).
+ let metadata = await promiseGlobalMetadata();
+ metadata.defaultEngineIdHash = "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),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ Assert.equal(Services.search.defaultEngine.name, kTestEngineName);
+ await promiseAfterSettings();
+
+ // Check that the current engine was saved.
+ let metadata = await promiseGlobalMetadata();
+ let currentEngine = Services.search.getEngineByName(kTestEngineName);
+ Assert.equal(metadata.defaultEngineId, currentEngine.id);
+
+ // Then set the engine back to the default through the API.
+ await Services.search.setDefault(
+ Services.search.getEngineByName(kDefaultEngineName),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ await promiseAfterSettings();
+
+ // Check that the current engine is no longer saved in the JSON file.
+ metadata = await promiseGlobalMetadata();
+ Assert.equal(metadata.defaultEngineId, "");
+});
+
+add_task(async function test_resetToOriginalDefaultEngine() {
+ Assert.equal(Services.search.defaultEngine.name, kDefaultEngineName);
+
+ await Services.search.setDefault(
+ Services.search.getEngineByName(kTestEngineName),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ Assert.equal(Services.search.defaultEngine.name, kTestEngineName);
+ await promiseAfterSettings();
+
+ Services.search.resetToAppDefaultEngine();
+ 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,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ 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..26052c1bea
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_sendSubmissionURL.js
@@ -0,0 +1,90 @@
+/* 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 = [
+ ["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 = [
+ ["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, search_url, search_url_get_params) {
+ await SearchTestUtils.installSearchExtension({
+ name,
+ search_url,
+ search_url_get_params,
+ });
+
+ let engine = Services.search.getEngineByName(name);
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ return engine;
+}
+
+add_task(async function test_submission_url_matching() {
+ Assert.ok(!Services.search.isInitialized);
+ let engineInfo;
+ let engine;
+
+ for (let [name, searchURL, searchParams] of SUBMISSION_YES) {
+ engine = await addAndMakeDefault(name, searchURL, searchParams);
+ engineInfo = Services.search.getDefaultEngineInfo();
+ Assert.equal(
+ engineInfo.defaultSearchEngineData.submissionURL,
+ (searchURL + "?" + searchParams).replace("{searchTerms}", "")
+ );
+ await Services.search.removeEngine(engine);
+ }
+
+ for (let [name, searchURL, searchParams] of SUBMISSION_NO) {
+ engine = await addAndMakeDefault(name, searchURL, searchParams);
+ engineInfo = 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,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ const engineInfo = 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..3be00f460e
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings.js
@@ -0,0 +1,616 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test initializing from the search settings.
+ */
+
+"use strict";
+
+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();
+ await Services.search.init();
+});
+
+async function loadSettingsFile(settingsFile, setVersion, setHashes) {
+ settingsTemplate = await readJSONFile(do_get_file(settingsFile));
+ if (setVersion) {
+ settingsTemplate.version = SearchUtils.SETTINGS_VERSION;
+ }
+
+ if (setHashes) {
+ settingsTemplate.metaData.hash = SearchUtils.getVerificationHash(
+ settingsTemplate.metaData.current
+ );
+ settingsTemplate.metaData.privateHash = SearchUtils.getVerificationHash(
+ settingsTemplate.metaData.private
+ );
+ }
+
+ 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");
+ let ss = Services.search.wrappedJSObject;
+
+ await loadSettingsFile(settingsFile, setVersion);
+
+ const settingsFileWritten = promiseAfterSettings();
+
+ await ss.reset();
+ await Services.search.init();
+
+ 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[0].id, "engine1@search.mozilla.orgdefault");
+
+ Assert.equal(
+ engines[1].name,
+ "engine2",
+ "Should have loaded the correct second engine"
+ );
+ Assert.equal(engines[1].alias, "", "Should have not set the alias");
+ Assert.equal(engines[1].hidden, true, "Should have hidden the engine");
+ Assert.equal(engines[1].id, "engine2@search.mozilla.orgdefault");
+
+ // The extra engine is the second in the list.
+ isSubObjectOf(EXPECTED_ENGINE.engine, engines[2]);
+ Assert.ok(engines[2].id, "test-addon-id@mozilla.orgdefault");
+
+ 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.getMetaDataAttribute("useSavedOrder"),
+ expectedUseDBValue,
+ "Should have set the useSavedOrder metadata correctly."
+ );
+
+ let migratedSettingsFile = await promiseSettingsData();
+
+ Assert.equal(
+ migratedSettingsFile.engines[0].id,
+ "engine1@search.mozilla.orgdefault"
+ );
+
+ removeSettingsFile();
+}
+
+add_task(async function test_legacy_setting_engine_properties() {
+ Services.prefs.setBoolPref(legacyUseSavedOrderPrefName, true);
+
+ let legacySettings = await readJSONFile(
+ do_get_file("data/search-legacy.json")
+ );
+
+ // Assert the engine ids have not been migrated yet
+ for (let engine of legacySettings.engines) {
+ Assert.ok(!("id" in engine));
+ }
+ Assert.ok(!("defaultEngineId" in legacySettings.metaData));
+ Assert.ok(!("privateDefaultEngineId" in legacySettings.metaData));
+
+ 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_legacy_setting_migration_with_undefined_metaData_current_and_private() {
+ let ss = Services.search.wrappedJSObject;
+
+ await loadSettingsFile("data/search-legacy.json", false);
+ const settingsFileWritten = promiseAfterSettings();
+
+ await ss.reset();
+ await Services.search.init();
+
+ await settingsFileWritten;
+
+ let migratedSettingsFile = await promiseSettingsData();
+
+ Assert.equal(
+ migratedSettingsFile.metaData.defaultEngineId,
+ "",
+ "When there is no metaData.current attribute in settings file, the migration should set the defaultEngineId to an empty string."
+ );
+ Assert.equal(
+ migratedSettingsFile.metaData.privateDefaultEngineId,
+ "",
+ "When there is no metaData.private attribute in settings file, the migration should set the privateDefaultEngineId to an empty string."
+ );
+
+ removeSettingsFile();
+ }
+);
+
+add_task(
+ async function test_legacy_setting_migration_with_correct_metaData_current_and_private_hashes() {
+ let ss = Services.search.wrappedJSObject;
+
+ await loadSettingsFile(
+ "data/search-legacy-correct-default-engine-hashes.json",
+ false,
+ true
+ );
+ const settingsFileWritten = promiseAfterSettings();
+
+ await ss.reset();
+ await Services.search.init();
+
+ await settingsFileWritten;
+
+ let migratedSettingsFile = await promiseSettingsData();
+
+ Assert.equal(
+ migratedSettingsFile.metaData.defaultEngineId,
+ "engine2@search.mozilla.orgdefault",
+ "When the metaData.current and associated hash are correct, the migration should set the defaultEngineId to the engine id."
+ );
+ Assert.equal(
+ migratedSettingsFile.metaData.privateDefaultEngineId,
+ "engine2@search.mozilla.orgdefault",
+ "When the metaData.private and associated hash are correct, the migration should set the privateDefaultEngineId to the private engine id."
+ );
+
+ removeSettingsFile();
+ }
+);
+
+add_task(
+ async function test_legacy_setting_migration_with_incorrect_metaData_current_and_private_hashes_app_provided() {
+ let ss = Services.search.wrappedJSObject;
+
+ // Here we are testing correct migration for the case that a user has set
+ // their default engine to an application provided engine (but not the app
+ // default).
+ //
+ // In this case we should ignore invalid hashes for the default engines,
+ // and allow the select default to remain. This covers the case where
+ // a user has copied a profile from a different directory.
+ // See SearchService._getEngineDefault for more details.
+
+ await loadSettingsFile(
+ "data/search-legacy-wrong-default-engine-hashes.json",
+ false,
+ false
+ );
+ const settingsFileWritten = promiseAfterSettings();
+
+ await ss.reset();
+ await Services.search.init();
+
+ await settingsFileWritten;
+
+ let migratedSettingsFile = await promiseSettingsData();
+
+ Assert.equal(
+ migratedSettingsFile.metaData.defaultEngineId,
+ "engine2@search.mozilla.orgdefault",
+ "Should ignore invalid metaData.hash when the default engine is application provided."
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have the correct engine set as default"
+ );
+
+ Assert.equal(
+ migratedSettingsFile.metaData.privateDefaultEngineId,
+ "engine2@search.mozilla.orgdefault",
+ "Should ignore invalid metaData.privateHash when the default private engine is application provided."
+ );
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "engine2",
+ "Should have the correct engine set as default private"
+ );
+
+ removeSettingsFile();
+ }
+);
+
+add_task(
+ async function test_legacy_setting_migration_with_incorrect_metaData_current_and_private_hashes_third_party() {
+ let ss = Services.search.wrappedJSObject;
+
+ // This test is checking that if the user has set a third-party engine as
+ // default, and the verification hash is invalid, then we do not copy
+ // the default engine setting.
+
+ await loadSettingsFile(
+ "data/search-legacy-wrong-third-party-engine-hashes.json",
+ false,
+ false
+ );
+ const settingsFileWritten = promiseAfterSettings();
+
+ await ss.reset();
+ await Services.search.init();
+
+ await settingsFileWritten;
+
+ let migratedSettingsFile = await promiseSettingsData();
+
+ Assert.equal(
+ migratedSettingsFile.metaData.defaultEngineId,
+ "",
+ "Should reset the default engine when metaData.hash is invalid and the engine is not application provided."
+ );
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "engine1",
+ "Should have reset the default engine"
+ );
+
+ Assert.equal(
+ migratedSettingsFile.metaData.privateDefaultEngineId,
+ "",
+ "Should reset the default engine when metaData.privateHash is invalid and the engine is not application provided."
+ );
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "engine1",
+ "Should have reset the default private engine"
+ );
+
+ removeSettingsFile();
+ }
+);
+
+add_task(async function test_current_setting_engine_properties() {
+ await checkLoadSettingProperties("data/search.json", true, false);
+});
+
+add_task(async function test_settings_metadata_properties() {
+ let ss = Services.search.wrappedJSObject;
+
+ await loadSettingsFile("data/search.json");
+
+ const settingsFileWritten = promiseAfterSettings();
+ await ss.reset();
+ await Services.search.init();
+
+ await settingsFileWritten;
+
+ let metaDataProperties = [
+ "locale",
+ "region",
+ "channel",
+ "experiment",
+ "distroID",
+ ];
+
+ for (let name of metaDataProperties) {
+ Assert.notEqual(
+ ss._settings.getMetaDataAttribute(`${name}`),
+ undefined,
+ `Search settings should have ${name} property defined.`
+ );
+ }
+
+ removeSettingsFile();
+});
+
+add_task(async function test_settings_write_when_settings_changed() {
+ let ss = Services.search.wrappedJSObject;
+ await loadSettingsFile("data/search.json");
+
+ const settingsFileWritten = promiseAfterSettings();
+ await ss.reset();
+ await Services.search.init();
+ await settingsFileWritten;
+
+ Assert.ok(
+ ss._settings.isCurrentAndCachedSettingsEqual(),
+ "Settings and cached settings should be the same after search service initializaiton."
+ );
+
+ const settingsFileWritten2 = promiseAfterSettings();
+ ss._settings.setMetaDataAttribute("value", "test");
+
+ Assert.ok(
+ !ss._settings.isCurrentAndCachedSettingsEqual(),
+ "Settings should differ from cached settings after a new attribute is set."
+ );
+
+ await settingsFileWritten2;
+ info("Settings write complete");
+
+ Assert.ok(
+ ss._settings.isCurrentAndCachedSettingsEqual(),
+ "Settings and cached settings should be the same after new attribte on settings is written."
+ );
+
+ removeSettingsFile();
+});
+
+add_task(async function test_set_and_get_engine_metadata_attribute() {
+ let ss = Services.search.wrappedJSObject;
+ await loadSettingsFile("data/search.json");
+
+ const settingsFileWritten = promiseAfterSettings();
+ await ss.reset();
+ await Services.search.init();
+ await settingsFileWritten;
+
+ let engines = await ss.getEngines();
+ const settingsFileWritten2 = promiseAfterSettings();
+ ss._settings.setEngineMetaDataAttribute(engines[0].name, "value", "test");
+ await settingsFileWritten2;
+
+ Assert.equal(
+ "test",
+ ss._settings.getEngineMetaDataAttribute(engines[0].name, "value"),
+ `${engines[0].name}'s metadata property "value" should be set as "test" after calling getEngineMetaDataAttribute.`
+ );
+
+ let userSettings = await ss._settings.get();
+ let engine = userSettings.engines.find(e => e._name == engines[0].name);
+
+ Assert.equal(
+ "test",
+ engine._metaData.value,
+ `${engines[0].name}'s metadata property "value" should be set as "test" from settings file.`
+ );
+
+ removeSettingsFile();
+});
+
+add_task(
+ async function test_settings_write_prevented_when_settings_unchanged() {
+ let ss = Services.search.wrappedJSObject;
+ await loadSettingsFile("data/search.json");
+
+ const settingsFileWritten = promiseAfterSettings();
+ await ss.reset();
+ await Services.search.init();
+ await settingsFileWritten;
+
+ Assert.ok(
+ ss._settings.isCurrentAndCachedSettingsEqual(),
+ "Settings and cached settings should be the same after search service initializaiton."
+ );
+
+ // Update settings.
+ const settingsFileWritten2 = promiseAfterSettings();
+ ss._settings.setMetaDataAttribute("value", "test");
+
+ Assert.ok(
+ !ss._settings.isCurrentAndCachedSettingsEqual(),
+ "Settings should differ from cached settings after a new attribute is set."
+ );
+ await settingsFileWritten2;
+
+ // Set the same attribute as before to ensure there was no change.
+ // Settings write should be prevented.
+ let promiseWritePrevented = SearchTestUtils.promiseSearchNotification(
+ "write-prevented-when-settings-unchanged"
+ );
+ ss._settings.setMetaDataAttribute("value", "test");
+
+ Assert.ok(
+ ss._settings.isCurrentAndCachedSettingsEqual(),
+ "Settings and cached settings should be the same."
+ );
+ await promiseWritePrevented;
+
+ removeSettingsFile();
+ }
+);
+
+/**
+ * Test that the JSON settings written in the profile is correct.
+ */
+add_task(async function test_settings_write() {
+ let ss = Services.search.wrappedJSObject;
+ info("test settings writing");
+
+ await loadSettingsFile("data/search.json");
+
+ const settingsFileWritten = promiseAfterSettings();
+ await ss.reset();
+ await Services.search.init();
+ await settingsFileWritten;
+
+ 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.setMetaDataAttribute("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 => {
+ let status = disable ? "success" : "failed";
+ Services.search.wrappedJSObject.forceInitializationStatusForTests(status);
+ });
+});
+
+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: "",
+ 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..12298155f1
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings_broken.js
@@ -0,0 +1,131 @@
+/* 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.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+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 IOUtils.writeJSON(
+ PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME),
+ enginesSettings,
+ { compress: true }
+ );
+});
+
+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..b269fcafca
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings_duplicate.js
@@ -0,0 +1,134 @@
+/* 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.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+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 IOUtils.write(
+ PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME),
+ new TextEncoder().encode(JSON.stringify(enginesSettings)),
+ { compress: true }
+ );
+});
+
+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..2c69889c09
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings_good.js
@@ -0,0 +1,103 @@
+/* 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.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+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,
+ // We use the second engine here so that the user's default is set
+ // to something different, and hence so that we exercise the appropriate
+ // code paths.
+ defaultEngineId: "engine2@search.mozilla.orgdefault",
+ defaultEngineIdHash: "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.defaultEngineIdHash =
+ SearchUtils.getVerificationHash(enginesSettings.metaData.defaultEngineId);
+ enginesSettings.metaData.visibleDefaultEnginesHash =
+ SearchUtils.getVerificationHash(
+ enginesSettings.metaData.visibleDefaultEngines
+ );
+ const appInfo = getAppInfo();
+ enginesSettings.buildID = appInfo.platformBuildID;
+ enginesSettings.appVersion = appInfo.version;
+
+ await IOUtils.writeJSON(
+ PathUtils.join(PathUtils.profileDir, SETTINGS_FILENAME),
+ enginesSettings,
+ { compress: true }
+ );
+});
+
+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.equal(
+ Services.search.defaultEngine.name,
+ "engine2",
+ "Should have the expected default engine"
+ );
+ Assert.deepEqual(
+ engines.map(e => e.name),
+ ["engine1", "engine2"],
+ "Should have the expected application provided 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..b02775bbd9
--- /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.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+
+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_migration_ids.js b/toolkit/components/search/tests/xpcshell/test_settings_migration_ids.js
new file mode 100644
index 0000000000..f5ccb8a301
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings_migration_ids.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test migration of user, enterprise policy and OpenSearch engines
+ * from when engines were referenced by name rather than id.
+ *
+ * Add-ons and default engine ids are tested in test_settings.js.
+ */
+
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+const enterprisePolicy = {
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Policy",
+ Encoding: "windows-1252",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+};
+
+/**
+ * Loads the settings file and ensures it has not already been migrated.
+ *
+ * @param {string} settingsFile The settings file to load
+ */
+async function loadSettingsFile(settingsFile) {
+ let settingsTemplate = await readJSONFile(do_get_file(settingsFile));
+
+ Assert.less(
+ settingsTemplate.version,
+ 7,
+ "Should be a version older than when indexing engines by id was introduced"
+ );
+ for (let engine of settingsTemplate.engines) {
+ Assert.ok(!("id" in engine));
+ }
+
+ await promiseSaveSettingsData(settingsTemplate);
+}
+
+/**
+ * Test reading from search.json.mozlz4
+ */
+add_task(async function setup() {
+ // This initializes the policy engine for xpcshell tests
+ let policies = Cc["@mozilla.org/enterprisepolicies;1"].getService(
+ Ci.nsIObserver
+ );
+ policies.observe(null, "policies-startup", null);
+
+ Services.prefs
+ .getDefaultBranch(SearchUtils.BROWSER_SEARCH_PREF + "param.")
+ .setCharPref("test", "expected");
+
+ await SearchTestUtils.useTestEngines("data1");
+ await AddonTestUtils.promiseStartupManager();
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(enterprisePolicy);
+ // Setting the enterprise policy starts the search service initialising,
+ // so we wait for that to complete before starting the test.
+ await Services.search.init();
+});
+
+/**
+ * Tests that an installed engine matches the expected data.
+ *
+ * @param {object} expectedData The expected data for the engine
+ */
+async function assertInstalledEngineMatches(expectedData) {
+ let engine = await Services.search.getEngineByName(expectedData.name);
+
+ Assert.ok(engine, `Should have found the ${expectedData.type} engine`);
+ if (expectedData.idLength) {
+ Assert.equal(
+ engine.id.length,
+ expectedData.idLength,
+ "Should have been given an id"
+ );
+ } else {
+ Assert.equal(engine.id, expectedData.id, "Should have the expected id");
+ }
+ Assert.equal(engine.alias, expectedData.alias, "Should have kept the alias");
+}
+
+add_task(async function test_migration_from_pre_ids() {
+ await loadSettingsFile("data/search-legacy-no-ids.json");
+
+ const settingsFileWritten = promiseAfterSettings();
+
+ await Services.search.wrappedJSObject.reset();
+ await Services.search.init();
+
+ await settingsFileWritten;
+
+ await assertInstalledEngineMatches({
+ type: "OpenSearch",
+ name: "Bugzilla@Mozilla",
+ idLength: 36,
+ alias: "bugzillaAlias",
+ });
+ await assertInstalledEngineMatches({
+ type: "Enterprise Policy",
+ name: "Policy",
+ id: "policy-Policy",
+ alias: "PolicyAlias",
+ });
+ await assertInstalledEngineMatches({
+ type: "User",
+ name: "User",
+ idLength: 36,
+ alias: "UserAlias",
+ });
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_settings_migration_loadPath.js b/toolkit/components/search/tests/xpcshell/test_settings_migration_loadPath.js
new file mode 100644
index 0000000000..e6a7cbce00
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings_migration_loadPath.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test migration load path for user, enterprise policy and add-on
+ * engines.
+ */
+
+"use strict";
+
+const { EnterprisePolicyTesting } = ChromeUtils.importESModule(
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs"
+);
+
+const enterprisePolicy = {
+ policies: {
+ SearchEngines: {
+ Add: [
+ {
+ Name: "Policy",
+ Encoding: "windows-1252",
+ URLTemplate: "http://example.com/?q={searchTerms}",
+ },
+ ],
+ },
+ },
+};
+
+add_task(async function setup() {
+ // This initializes the policy engine for xpcshell tests
+ let policies = Cc["@mozilla.org/enterprisepolicies;1"].getService(
+ Ci.nsIObserver
+ );
+ policies.observe(null, "policies-startup", null);
+
+ Services.prefs
+ .getDefaultBranch(SearchUtils.BROWSER_SEARCH_PREF + "param.")
+ .setCharPref("test", "expected");
+
+ await SearchTestUtils.useTestEngines("data1");
+ await AddonTestUtils.promiseStartupManager();
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson(enterprisePolicy);
+ // Setting the enterprise policy starts the search service initialising,
+ // so we wait for that to complete before starting the test, we can
+ // then also add an extra add-on engine.
+ await Services.search.init();
+ let settingsFileWritten = promiseAfterSettings();
+ await SearchTestUtils.installSearchExtension();
+ await settingsFileWritten;
+});
+
+/**
+ * Loads the settings file and ensures it has not already been migrated.
+ */
+add_task(async function test_load_and_check_settings() {
+ let settingsTemplate = await readJSONFile(
+ do_get_file("data/search-legacy-old-loadPaths.json")
+ );
+
+ Assert.less(
+ settingsTemplate.version,
+ 8,
+ "Should be a version older than when indexing engines by id was introduced"
+ );
+ let engine = settingsTemplate.engines.find(e => e.id == "policy-Policy");
+ Assert.equal(
+ engine._loadPath,
+ "[other]addEngineWithDetails:set-via-policy",
+ "Should have a old style load path for the policy engine"
+ );
+ engine = settingsTemplate.engines.find(
+ e => e.id == "bbc163e7-7b1a-47aa-a32c-c59062de2754"
+ );
+ Assert.equal(
+ engine._loadPath,
+ "[other]addEngineWithDetails:set-via-user",
+ "Should have a old style load path for the user engine"
+ );
+ engine = settingsTemplate.engines.find(
+ e => e.id == "example@tests.mozilla.orgdefault"
+ );
+ Assert.equal(
+ engine._loadPath,
+ "[other]addEngineWithDetails:example@tests.mozilla.org",
+ "Should have a old style load path for the add-on engine"
+ );
+
+ await promiseSaveSettingsData(settingsTemplate);
+});
+
+/**
+ * Tests that an installed engine matches the expected data.
+ *
+ * @param {object} expectedData The expected data for the engine
+ */
+async function assertInstalledEngineMatches(expectedData) {
+ let engine = await Services.search.getEngineByName(expectedData.name);
+
+ Assert.ok(engine, `Should have found the ${expectedData.type} engine`);
+ Assert.equal(
+ engine.wrappedJSObject._loadPath,
+ expectedData.loadPath,
+ "Should have migrated the loadPath"
+ );
+}
+
+add_task(async function test_migration_from_pre_ids() {
+ const settingsFileWritten = promiseAfterSettings();
+
+ await Services.search.wrappedJSObject.reset();
+ await Services.search.init();
+
+ await settingsFileWritten;
+
+ await assertInstalledEngineMatches({
+ type: "Policy",
+ name: "Policy",
+ loadPath: "[policy]",
+ });
+ await assertInstalledEngineMatches({
+ type: "User",
+ name: "User",
+ loadPath: "[user]",
+ });
+ await assertInstalledEngineMatches({
+ type: "Add-on",
+ name: "Example",
+ loadPath: "[addon]example@tests.mozilla.org",
+ });
+});
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..14d3bb8e51
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings_none.js
@@ -0,0 +1,50 @@
+/* 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());
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: `${gDataUrl}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..459a9d8aa1
--- /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";
+
+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"
+ );
+});
+
+add_task(async function test_obsolete_app_engine() {
+ await checkLoadSettingProperties("data/search-obsolete-app.json", "App");
+});
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..f4ef840838
--- /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";
+
+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
+ let settingsFileWritten = promiseAfterSettings();
+ await Services.search.init(false);
+ Services.search.wrappedJSObject._removeObservers();
+ await settingsFileWritten;
+});
+
+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_settings_persist_diff_locale_same_name.js b/toolkit/components/search/tests/xpcshell/test_settings_persist_diff_locale_same_name.js
new file mode 100644
index 0000000000..3c0b930bc8
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_settings_persist_diff_locale_same_name.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const CONFIG = [
+ {
+ webExtension: { id: "engine@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ },
+ {
+ // 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"],
+ },
+ },
+ ],
+ },
+];
+
+add_task(async function setup() {
+ await SearchTestUtils.useTestEngines("data", null, CONFIG);
+ await AddonTestUtils.promiseStartupManager();
+ let settingsFileWritten = promiseAfterSettings();
+ Region._setHomeRegion("US", false);
+ await Services.search.init();
+ await settingsFileWritten;
+});
+
+add_task(async function test_settings_persist_diff_locale_same_name() {
+ let settingsFileWritten = promiseAfterSettings();
+ let engine1 = Services.search.getEngineByName("engine-same-name");
+ Services.search.moveEngine(engine1, 0);
+
+ let engine2 = Services.search.getEngineByName("Test search engine");
+ Services.search.moveEngine(engine2, 1);
+ // Ensure we have saved the settings before we restart below.
+ await settingsFileWritten;
+
+ Assert.deepEqual(
+ (await Services.search.getEngines()).map(e => e.name),
+ ["engine-same-name", "Test search engine"],
+ "Should have set the engines to the expected order"
+ );
+
+ // Setting the region to FR will change the engine id, but use the same name.
+ Region._setHomeRegion("FR", false);
+
+ // Pretend we are restarting.
+ Services.search.wrappedJSObject.reset();
+ await Services.search.init();
+
+ Assert.deepEqual(
+ (await Services.search.getEngines()).map(e => e.name),
+ ["engine-same-name", "Test search engine"],
+ "Should have retained the engines in the expected order"
+ );
+});
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..71b75fcdc1
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_sort_orders-no-hints.js
@@ -0,0 +1,69 @@
+/* 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._cachedSortedEngines = 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 SearchTestUtils.installSearchExtension({ name: "nonbuiltin1" });
+
+ // 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.setMetaDataAttribute(
+ "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..4609c7f4fb
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_sort_orders.js
@@ -0,0 +1,91 @@
+/* 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 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._cachedSortedEngines = 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 SearchTestUtils.installSearchExtension({ name: "nonbuiltin1" });
+
+ // 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.setMetaDataAttribute(
+ "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_telemetry_event_default.js b/toolkit/components/search/tests/xpcshell/test_telemetry_event_default.js
new file mode 100644
index 0000000000..a73ef6be58
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_telemetry_event_default.js
@@ -0,0 +1,293 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for the default engine telemetry event that can be tested via xpcshell,
+ * related to changing or selecting a different configuration.
+ * Other tests are typically in browser mochitests.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+const BASE_CONFIG = [
+ {
+ webExtension: { id: "engine@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ default: "yes",
+ },
+];
+const MAIN_CONFIG = [
+ {
+ webExtension: { id: "engine@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ default: "no",
+ },
+ {
+ webExtension: { id: "engine-chromeicon@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ default: "yes-if-no-other",
+ },
+ {
+ webExtension: { id: "engine-fr@search.mozilla.org" },
+ appliesTo: [
+ { included: { everywhere: true } },
+ {
+ included: { locales: { matches: ["fr"] } },
+ excluded: { regions: ["DE"] },
+ default: "yes",
+ },
+ ],
+ default: "no",
+ },
+ {
+ webExtension: { id: "engine-pref@search.mozilla.org" },
+ appliesTo: [
+ { included: { everywhere: true } },
+ { included: { regions: ["DE"] }, default: "yes" },
+ ],
+ default: "no",
+ },
+ {
+ webExtension: { id: "engine2@search.mozilla.org" },
+ appliesTo: [
+ { included: { everywhere: true } },
+ { included: { everywhere: true }, experiment: "test1", default: "yes" },
+ ],
+ default: "no",
+ },
+];
+
+const testSearchEngine = {
+ id: "engine",
+ name: "Test search engine",
+ loadPath: "[addon]engine@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+};
+const testChromeIconEngine = {
+ id: "engine-chromeicon",
+ name: "engine-chromeicon",
+ loadPath: "[addon]engine-chromeicon@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+};
+const testFrEngine = {
+ id: "engine-fr",
+ name: "Test search engine (fr)",
+ loadPath: "[addon]engine-fr@search.mozilla.org",
+ submissionUrl: "https://www.google.fr/search?q=&ie=iso-8859-1&oe=iso-8859-1",
+};
+const testPrefEngine = {
+ id: "engine-pref",
+ name: "engine-pref",
+ loadPath: "[addon]engine-pref@search.mozilla.org",
+ submissionUrl: "https://www.google.com/search?q=",
+};
+const testEngine2 = {
+ id: "engine2",
+ name: "A second test engine",
+ loadPath: "[addon]engine2@search.mozilla.org",
+ submissionUrl: "https://duckduckgo.com/?q=",
+};
+
+function clearTelemetry() {
+ Services.telemetry.clearEvents();
+ Services.fog.testResetFOG();
+}
+
+async function checkTelemetry(
+ source,
+ prevEngine,
+ newEngine,
+ checkPrivate = false
+) {
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: checkPrivate ? "change_private" : "change_default",
+ value: source,
+ extra: {
+ prev_id: prevEngine?.id ?? "",
+ new_id: newEngine?.id ?? "",
+ new_name: newEngine?.name ?? "",
+ new_load_path: newEngine?.loadPath ?? "",
+ // Telemetry has a limit of 80 characters.
+ new_sub_url: newEngine?.submissionUrl.slice(0, 80) ?? "",
+ },
+ },
+ ],
+ { category: "search", method: "engine" }
+ );
+
+ let snapshot;
+ if (checkPrivate) {
+ snapshot = await Glean.searchEnginePrivate.changed.testGetValue();
+ } else {
+ snapshot = await Glean.searchEngineDefault.changed.testGetValue();
+ }
+ delete snapshot[0].timestamp;
+ Assert.deepEqual(
+ snapshot[0],
+ {
+ category: checkPrivate
+ ? "search.engine.private"
+ : "search.engine.default",
+ name: "changed",
+ extra: {
+ change_source: source,
+ previous_engine_id: prevEngine?.id ?? "",
+ new_engine_id: newEngine?.id ?? "",
+ new_display_name: newEngine?.name ?? "",
+ new_load_path: newEngine?.loadPath ?? "",
+ new_submission_url: newEngine?.submissionUrl ?? "",
+ },
+ },
+ "Should have received the correct event details"
+ );
+}
+
+let getVariableStub;
+
+add_setup(async () => {
+ Region._setHomeRegion("US", false);
+ Services.locale.availableLocales = [
+ ...Services.locale.availableLocales,
+ "en",
+ "fr",
+ ];
+ Services.locale.requestedLocales = ["en"];
+
+ sinon.spy(NimbusFeatures.searchConfiguration, "onUpdate");
+ sinon.stub(NimbusFeatures.searchConfiguration, "ready").resolves();
+ getVariableStub = sinon.stub(
+ NimbusFeatures.searchConfiguration,
+ "getVariable"
+ );
+ getVariableStub.returns(null);
+
+ SearchTestUtils.useMockIdleService();
+ Services.fog.initializeFOG();
+ sinon.stub(
+ Services.search.wrappedJSObject,
+ "_showRemovalOfSearchEngineNotificationBox"
+ );
+
+ await SearchTestUtils.useTestEngines("data", null, BASE_CONFIG);
+ await AddonTestUtils.promiseStartupManager();
+
+ await Services.search.init();
+});
+
+add_task(async function test_configuration_changes_default() {
+ clearTelemetry();
+
+ await SearchTestUtils.updateRemoteSettingsConfig(MAIN_CONFIG);
+
+ await checkTelemetry("config", testSearchEngine, testChromeIconEngine);
+});
+
+add_task(async function test_experiment_changes_default() {
+ clearTelemetry();
+
+ let reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+ getVariableStub.callsFake(name => (name == "experiment" ? "test1" : null));
+ NimbusFeatures.searchConfiguration.onUpdate.firstCall.args[0]();
+ await reloadObserved;
+
+ await checkTelemetry("experiment", testChromeIconEngine, testEngine2);
+
+ // Reset the stub so that we are no longer in an experiment.
+ getVariableStub.returns(null);
+});
+
+add_task(async function test_locale_changes_default() {
+ clearTelemetry();
+
+ let reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+ Services.locale.requestedLocales = ["fr"];
+ await reloadObserved;
+
+ await checkTelemetry("locale", testEngine2, testFrEngine);
+});
+
+add_task(async function test_region_changes_default() {
+ clearTelemetry();
+
+ let reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+ Region._setHomeRegion("DE", true);
+ await reloadObserved;
+
+ await checkTelemetry("region", testFrEngine, testPrefEngine);
+});
+
+add_task(async function test_user_changes_separate_private_pref() {
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ true
+ );
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+
+ await Services.search.setDefaultPrivate(
+ Services.search.getEngineByName("engine-chromeicon"),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ Assert.notEqual(
+ await Services.search.getDefault(),
+ await Services.search.getDefaultPrivate(),
+ "Should have different engines for the pre-condition"
+ );
+
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ false
+ );
+
+ clearTelemetry();
+
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ false
+ );
+
+ await checkTelemetry("user_private_split", testChromeIconEngine, null, true);
+
+ getVariableStub.returns(null);
+});
+
+add_task(async function test_experiment_with_separate_default_notifies() {
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault.ui.enabled",
+ false
+ );
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "separatePrivateDefault",
+ true
+ );
+
+ clearTelemetry();
+
+ getVariableStub.callsFake(name =>
+ name == "seperatePrivateDefaultUIEnabled" ? true : null
+ );
+ NimbusFeatures.searchConfiguration.onUpdate.firstCall.args[0]();
+
+ await checkTelemetry("experiment", null, testChromeIconEngine, true);
+
+ clearTelemetry();
+
+ // Reset the stub so that we are no longer in an experiment.
+ getVariableStub.returns(null);
+ NimbusFeatures.searchConfiguration.onUpdate.firstCall.args[0]();
+
+ await checkTelemetry("experiment", testChromeIconEngine, null, true);
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_userEngine.js b/toolkit/components/search/tests/xpcshell/test_userEngine.js
new file mode 100644
index 0000000000..659baf10ab
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_userEngine.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests that User Engines can be installed correctly.
+ */
+
+"use strict";
+
+add_task(async function setup() {
+ Services.fog.initializeFOG();
+ await AddonTestUtils.promiseStartupManager();
+ await Services.search.init();
+});
+
+add_task(async function test_user_engine() {
+ let promiseEngineAdded = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.ADDED,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+ await Services.search.addUserEngine(
+ "user",
+ "https://example.com/user?q={searchTerms}",
+ "u"
+ );
+ await promiseEngineAdded;
+
+ let engine = Services.search.getEngineByName("user");
+ Assert.ok(engine, "Should have installed the engine.");
+
+ Assert.equal(engine.name, "user", "Should have the correct name");
+ Assert.equal(engine.description, null, "Should not have a description");
+ Assert.deepEqual(engine.aliases, ["u"], "Should have the correct alias");
+
+ let submission = engine.getSubmission("foo");
+ Assert.equal(
+ submission.uri.spec,
+ "https://example.com/user?q=foo",
+ "Should have the correct search url"
+ );
+
+ submission = engine.getSubmission("foo", SearchUtils.URL_TYPE.SUGGEST_JSON);
+ Assert.equal(submission, null, "Should not have a suggest url");
+
+ Services.search.defaultEngine = engine;
+
+ await assertGleanDefaultEngine({
+ normal: {
+ engineId: "other-user",
+ displayName: "user",
+ loadPath: "[user]",
+ submissionUrl: "blank:",
+ verified: "verified",
+ },
+ });
+});
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..1f95b1e14b
--- /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.importESModule(
+ "resource://gre/modules/SearchService.sys.mjs"
+);
+
+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..c840009ab6
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_validate_manifests.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.importGlobalProperties(["fetch"]);
+
+const { ExtensionData } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+);
+
+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, false);
+ 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..41a7c0c0b2
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_webextensions_builtin_upgrade.js
@@ -0,0 +1,252 @@
+/* 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 { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+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, name = "Plain") {
+ return {
+ useAddonManager: "permanent",
+ manifest: {
+ name,
+ version,
+ browser_specific_settings: {
+ gecko: {
+ id: "plainengine@search.mozilla.org",
+ },
+ },
+ chrome_settings_overrides: {
+ search_provider: {
+ name,
+ 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,
+ browser_specific_settings: {
+ 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.ok(engine.isAppProvided);
+ 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();
+});
+
+add_task(async function test_upgrade_with_name_change() {
+ Assert.deepEqual(await getEngineNames(), [
+ "Multilocale AF",
+ "Multilocale AN",
+ "Plain",
+ ]);
+
+ let ext = ExtensionTestUtils.loadExtension(
+ makePlainExtension("2.0", "Plain2")
+ );
+ await ext.startup();
+ await AddonTestUtils.waitForSearchProviderStartup(ext);
+
+ Assert.deepEqual(await getEngineNames(), [
+ "Multilocale AF",
+ "Multilocale AN",
+ "Plain2",
+ ]);
+
+ let engine = await Services.search.getEngineByName("Plain2");
+ 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..4b006edd6c
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_webextensions_install.js
@@ -0,0 +1,205 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+async function getEngineNames() {
+ let engines = await Services.search.getEngines();
+ return engines.map(engine => engine._name);
+}
+
+add_task(async function setup() {
+ let server = useHttpServer();
+ server.registerContentType("sjs", "sjs");
+ 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",
+ },
+ { skipUnload: true }
+ );
+ 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 name = "Plain";
+ consoleAllowList.push(`An engine called ${name} already exists`);
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name,
+ search_url: "https://example.com/plain",
+ },
+ { skipUnload: true }
+ );
+
+ 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.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."
+ );
+});
+
+add_task(async function test_load_favicon_invalid() {
+ let observed = TestUtils.consoleMessageObserved(msg => {
+ return msg.wrappedJSObject.arguments[0].includes(
+ "Content type does not match expected"
+ );
+ });
+
+ // User installs a new search engine
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ favicon_url: `${gDataUrl}engine.xml`,
+ },
+ { skipUnload: true }
+ );
+
+ await observed;
+
+ let engine = await Services.search.getEngineByName("Example");
+ Assert.equal(null, engine.iconURI, "Should not have set an iconURI");
+
+ // User uninstalls their engine
+ await extension.awaitStartup();
+ await extension.unload();
+ await promiseAfterSettings();
+});
+
+add_task(async function test_load_favicon_invalid_redirect() {
+ let observed = TestUtils.consoleMessageObserved(msg => {
+ return msg.wrappedJSObject.arguments[0].includes(
+ "Content type does not match expected"
+ );
+ });
+
+ // User installs a new search engine
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ favicon_url: `${gDataUrl}/iconsRedirect.sjs?type=invalid`,
+ },
+ { skipUnload: true }
+ );
+
+ await observed;
+
+ let engine = await Services.search.getEngineByName("Example");
+ Assert.equal(null, engine.iconURI, "Should not have set an iconURI");
+
+ // User uninstalls their engine
+ await extension.awaitStartup();
+ await extension.unload();
+ await promiseAfterSettings();
+});
+
+add_task(async function test_load_favicon_redirect() {
+ let promiseEngineChanged = SearchTestUtils.promiseSearchNotification(
+ SearchUtils.MODIFIED_TYPE.CHANGED,
+ SearchUtils.TOPIC_ENGINE_MODIFIED
+ );
+
+ // User installs a new search engine
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ favicon_url: `${gDataUrl}/iconsRedirect.sjs`,
+ },
+ { skipUnload: true }
+ );
+
+ let engine = await Services.search.getEngineByName("Example");
+
+ await promiseEngineChanged;
+
+ Assert.ok(engine.iconURI, "Should have set an iconURI");
+ Assert.ok(
+ engine.iconURI.spec.startsWith("data:image/x-icon;base64,"),
+ "Should have saved the expected content type for the icon"
+ );
+
+ // User uninstalls their engine
+ await extension.awaitStartup();
+ await extension.unload();
+ await promiseAfterSettings();
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_language_switch.js b/toolkit/components/search/tests/xpcshell/test_webextensions_language_switch.js
new file mode 100644
index 0000000000..e23a2ff433
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_webextensions_language_switch.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+add_task(async function setup() {
+ Services.locale.availableLocales = [
+ ...Services.locale.availableLocales,
+ "en",
+ "de",
+ "fr",
+ ];
+ Services.locale.requestedLocales = ["en"];
+
+ await SearchTestUtils.useTestEngines("data1");
+ await promiseStartupManager();
+ await Services.search.init();
+ await promiseAfterSettings();
+
+ registerCleanupFunction(promiseShutdownManager);
+});
+
+add_task(async function test_language_switch_changes_name() {
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "__MSG_engineName__",
+ id: "engine@tests.mozilla.org",
+ search_url_get_params: `q={searchTerms}&version=1.0`,
+ default_locale: "en",
+ version: "1.0",
+ },
+ { skipUnload: false },
+ {
+ "_locales/en/messages.json": {
+ engineName: {
+ message: "English Name",
+ description: "The Name",
+ },
+ },
+ "_locales/fr/messages.json": {
+ engineName: {
+ message: "French Name",
+ description: "The Name",
+ },
+ },
+ }
+ );
+
+ let engine = Services.search.getEngineById("engine@tests.mozilla.orgdefault");
+ Assert.ok(!!engine, "Should have loaded the engine");
+ Assert.equal(
+ engine.name,
+ "English Name",
+ "Should have loaded the English version of the name"
+ );
+
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ let promiseChanged = TestUtils.topicObserved(
+ "browser-search-engine-modified",
+ (eng, verb) => verb == "engine-changed"
+ );
+
+ await promiseSetLocale("fr");
+
+ await promiseChanged;
+
+ engine = Services.search.getEngineById("engine@tests.mozilla.orgdefault");
+ Assert.ok(!!engine, "Should still be available");
+ Assert.equal(
+ engine.name,
+ "French Name",
+ "Should have updated to the French version of the name"
+ );
+
+ Assert.equal(
+ (await Services.search.getDefault()).id,
+ engine.id,
+ "Should have kept the default engine the same"
+ );
+
+ promiseChanged = TestUtils.topicObserved(
+ "browser-search-engine-modified",
+ (eng, verb) => verb == "engine-changed"
+ );
+
+ // Check for changing to a locale the add-on doesn't have.
+ await promiseSetLocale("de");
+
+ await promiseChanged;
+
+ engine = Services.search.getEngineById("engine@tests.mozilla.orgdefault");
+ Assert.ok(!!engine, "Should still be available");
+ Assert.equal(
+ engine.name,
+ "English Name",
+ "Should have fallen back to the default locale (English) version of the name"
+ );
+
+ Assert.equal(
+ (await Services.search.getDefault()).id,
+ engine.id,
+ "Should have kept the default engine the same"
+ );
+});
diff --git a/toolkit/components/search/tests/xpcshell/test_webextensions_migrate_to.js b/toolkit/components/search/tests/xpcshell/test_webextensions_migrate_to.js
new file mode 100644
index 0000000000..b9e78203ca
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_webextensions_migrate_to.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test migrating legacy add-on engines in background.
+ */
+
+"use strict";
+
+add_task(async function setup() {
+ useHttpServer("opensearch");
+ await AddonTestUtils.promiseStartupManager();
+ await SearchTestUtils.useTestEngines("data1");
+
+ let data = await readJSONFile(do_get_file("data/search-migration.json"));
+
+ await promiseSaveSettingsData(data);
+
+ await Services.search.init();
+
+ // We need the extension installed for this test, but we do not want to
+ // trigger the functions that happen on installation, so stub that out.
+ // The manifest already has details of this engine.
+ let oldFunc = Services.search.wrappedJSObject.addEnginesFromExtension;
+ Services.search.wrappedJSObject.addEnginesFromExtension = () => {};
+
+ // Add the add-on so add-on manager has a valid item.
+ await SearchTestUtils.installSearchExtension({
+ id: "simple",
+ name: "simple search",
+ search_url: "https://example.com/",
+ });
+
+ Services.search.wrappedJSObject.addEnginesFromExtension = oldFunc;
+});
+
+add_task(async function test_migrateLegacyEngineDifferentName() {
+ await Services.search.init();
+
+ let engine = Services.search.getEngineByName("simple");
+ Assert.ok(engine, "Should have the legacy add-on engine.");
+
+ // Set this engine as default, the new engine should become the default
+ // after migration.
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ engine = Services.search.getEngineByName("simple search");
+ Assert.ok(engine, "Should have the WebExtension engine.");
+
+ await Services.search.runBackgroundChecks();
+
+ engine = Services.search.getEngineByName("simple");
+ Assert.ok(!engine, "Should have removed the legacy add-on engine");
+
+ engine = Services.search.getEngineByName("simple search");
+ Assert.ok(engine, "Should have kept the WebExtension engine.");
+
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ engine.name,
+ "Should have switched to the WebExtension engine as default."
+ );
+});
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..0b55bca424
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_webextensions_startup_remove.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ENGINE_ID = "enginetest@example.com";
+let xpi;
+let profile = do_get_profile().clone();
+
+add_task(async function setup() {
+ await SearchTestUtils.useTestEngines("data1");
+ xpi = AddonTestUtils.createTempWebExtensionFile({
+ manifest: {
+ version: "1.0",
+ browser_specific_settings: {
+ 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,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ 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 IOUtils.remove(
+ PathUtils.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..555e2ae2f1
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_webextensions_upgrade.js
@@ -0,0 +1,188 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+add_task(async function setup() {
+ await SearchTestUtils.useTestEngines("data1");
+ await promiseStartupManager();
+ await Services.search.init();
+ await promiseAfterSettings();
+
+ registerCleanupFunction(promiseShutdownManager);
+});
+
+add_task(async function test_basic_upgrade() {
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ version: "1.0",
+ search_url_get_params: `q={searchTerms}&version=1.0`,
+ keyword: "foo",
+ },
+ { skipUnload: true }
+ );
+
+ 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");
+
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ let promiseChanged = TestUtils.topicObserved(
+ "browser-search-engine-modified",
+ (eng, verb) => verb == "engine-changed"
+ );
+
+ let manifest = SearchTestUtils.createEngineManifest({
+ version: "2.0",
+ search_url_get_params: `q={searchTerms}&version=2.0`,
+ keyword: "bar",
+ });
+ await extension.upgrade({
+ useAddonManager: "permanent",
+ manifest,
+ });
+ 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");
+
+ Assert.equal(
+ Services.search.defaultEngine.name,
+ "Example",
+ "Should have retained the same default engine"
+ );
+
+ await extension.unload();
+ await promiseAfterSettings();
+});
+
+add_task(async function test_upgrade_changes_name() {
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "engine",
+ id: "engine@tests.mozilla.org",
+ search_url_get_params: `q={searchTerms}&version=1.0`,
+ version: "1.0",
+ },
+ { skipUnload: true }
+ );
+
+ let engine = Services.search.getEngineByName("engine");
+ Assert.ok(!!engine, "Should have loaded the engine");
+
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ // When we add engines currently, we normally force using the saved order.
+ // Reset that here, so we can check the order is reset in the case this
+ // is a application provided engine change.
+ Services.search.wrappedJSObject._settings.setMetaDataAttribute(
+ "useSavedOrder",
+ false
+ );
+ Services.search.getEngineByName("engine1").wrappedJSObject._orderHint = null;
+ Services.search.getEngineByName("engine2").wrappedJSObject._orderHint = null;
+
+ Assert.deepEqual(
+ (await Services.search.getVisibleEngines()).map(e => e.name),
+ ["engine1", "engine2", "engine"],
+ "Should have the expected order initially"
+ );
+
+ let promiseChanged = TestUtils.topicObserved(
+ "browser-search-engine-modified",
+ (eng, verb) => verb == "engine-changed"
+ );
+
+ let manifest = SearchTestUtils.createEngineManifest({
+ name: "Bar",
+ id: "engine@tests.mozilla.org",
+ search_url_get_params: `q={searchTerms}&version=2.0`,
+ version: "2.0",
+ });
+ await extension.upgrade({
+ useAddonManager: "permanent",
+ manifest,
+ });
+ await AddonTestUtils.waitForSearchProviderStartup(extension);
+
+ await promiseChanged;
+
+ engine = Services.search.getEngineByName("Bar");
+ Assert.ok(!!engine, "Should be able to get the new engine");
+
+ Assert.equal(
+ (await Services.search.getDefault()).name,
+ "Bar",
+ "Should have kept the default engine the same"
+ );
+
+ Assert.deepEqual(
+ (await Services.search.getVisibleEngines()).map(e => e.name),
+ // Expected order: Default, then others in alphabetical.
+ ["engine1", "Bar", "engine2"],
+ "Should have updated the engine order"
+ );
+
+ await extension.unload();
+ await promiseAfterSettings();
+});
+
+add_task(async function test_upgrade_to_existing_name_not_allowed() {
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "engine",
+ search_url_get_params: `q={searchTerms}&version=1.0`,
+ version: "1.0",
+ },
+ { skipUnload: true }
+ );
+
+ let engine = Services.search.getEngineByName("engine");
+ Assert.ok(!!engine, "Should have loaded the engine");
+
+ let promise = AddonTestUtils.waitForSearchProviderStartup(extension);
+ let name = "engine1";
+ consoleAllowList.push(`An engine called ${name} already exists`);
+ let manifest = SearchTestUtils.createEngineManifest({
+ name,
+ search_url_get_params: `q={searchTerms}&version=2.0`,
+ version: "2.0",
+ });
+ await extension.upgrade({
+ useAddonManager: "permanent",
+ manifest,
+ });
+ await promise;
+
+ Assert.equal(
+ Services.search.getEngineByName("engine1").getSubmission("").uri.spec,
+ "https://1.example.com/",
+ "Should have not changed the original engine"
+ );
+
+ console.log((await Services.search.getEngines()).map(e => e.name));
+
+ engine = Services.search.getEngineByName("engine");
+ Assert.ok(!!engine, "Should still be able to get the engine by the old name");
+
+ 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..b5e71d2560
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/test_webextensions_valid.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils;
+
+let extension;
+let oldRemoveEngineFunc;
+
+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(
+ {},
+ { skipUnload: true }
+ );
+ 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.runBackgroundChecks();
+
+ 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.runBackgroundChecks();
+
+ 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.runBackgroundChecks();
+
+ 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",
+ browser_specific_settings: {
+ gecko: {
+ id: "example@tests.mozilla.org",
+ },
+ },
+ },
+ };
+
+ await extension.upgrade(extensionInfo);
+
+ await Services.search.runBackgroundChecks();
+
+ 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.runBackgroundChecks();
+
+ 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.runBackgroundChecks();
+
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "browser.searchinit.engine_invalid_webextension",
+ extensionId,
+ 1
+ );
+
+ await oldRemoveEngineFunc(Services.search.getEngineByName("Example"));
+});
diff --git a/toolkit/components/search/tests/xpcshell/xpcshell.ini b/toolkit/components/search/tests/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..4f0be19aa6
--- /dev/null
+++ b/toolkit/components/search/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,196 @@
+[DEFAULT]
+firefox-appdir = browser
+head = head_search.js
+dupe-manifest =
+tags=searchmain
+skip-if = toolkit == 'android'
+prefs =
+ browser.search.removeEngineInfobar.enabled=true
+
+support-files =
+ data/engine.xml
+ data/engine/manifest.json
+ data/engine2.xml
+ data/engine2/manifest.json
+ data/engine-app/manifest.json
+ data/engine-diff-name/manifest.json
+ data/engine-diff-name/_locales/en/messages.json
+ data/engine-diff-name/_locales/gd/messages.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-purposes/manifest.json
+ data/engine-resourceicon/manifest.json
+ data/engine-resourceicon/_locales/en/messages.json
+ data/engine-resourceicon/_locales/gd/messages.json
+ 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/iconsRedirect.sjs
+ data/search.json
+ data/search-legacy.json
+ data/search-legacy-correct-default-engine-hashes.json
+ data/search-legacy-no-ids.json
+ data/search-legacy-old-loadPaths.json
+ data/search-legacy-wrong-default-engine-hashes.json
+ data/search-legacy-wrong-third-party-engine-hashes.json
+ data/search-obsolete-app.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/exp2/manifest.json
+ data1/exp3/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_async.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_experiments.js]
+[test_defaultEngine.js]
+[test_defaultPrivateEngine.js]
+[test_engine_alias.js]
+[test_engine_ids.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.js]
+[test_initialization_with_region.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]
+support-files =
+ opensearch/chromeicon.xml
+ opensearch/resourceicon.xml
+[test_opensearch_install_errors.js]
+support-files = opensearch/invalid.xml
+[test_opensearch_telemetry.js]
+support-files =
+ opensearch/secure-and-securely-updated1.xml
+ opensearch/secure-and-securely-updated2.xml
+ opensearch/secure-and-securely-updated3.xml
+ opensearch/secure-and-securely-updated-insecure-form.xml
+ opensearch/secure-and-insecurely-updated1.xml
+ opensearch/secure-and-insecurely-updated2.xml
+ opensearch/insecure-and-securely-updated1.xml
+ opensearch/insecure-and-insecurely-updated1.xml
+ opensearch/insecure-and-insecurely-updated2.xml
+ opensearch/secure-and-no-update-url1.xml
+ opensearch/insecure-and-no-update-url1.xml
+ opensearch/secure-localhost.xml
+ opensearch/secure-onionv2.xml
+ opensearch/secure-onionv3.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_appDefaultEngine.js]
+[test_override_allowlist.js]
+[test_parseSubmissionURL.js]
+[test_policyEngine.js]
+[test_pref.js]
+skip-if = nightly_build
+[test_purpose.js]
+[test_reload_engines_experiment.js]
+[test_reload_engines_locales.js]
+[test_reload_engines.js]
+[test_remove_profile_engine.js]
+[test_remove_engine_notification_box.js]
+[test_searchUrlDomain.js]
+[test_save_sorted_engines.js]
+[test_SearchStaticData.js]
+[test_searchSuggest_cookies.js]
+[test_searchSuggest_extraParams.js]
+[test_searchSuggest_private.js]
+[test_searchSuggest.js]
+[test_searchTermFromResult.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_migration_ids.js]
+[test_settings_migration_loadPath.js]
+[test_settings_none.js]
+[test_settings_obsolete.js]
+[test_settings_persist_diff_locale_same_name.js]
+[test_settings_persist.js]
+[test_settings.js]
+[test_sort_orders-no-hints.js]
+[test_sort_orders.js]
+[test_telemetry_event_default.js]
+[test_userEngine.js]
+[test_validate_engines.js]
+[test_validate_manifests.js]
+[test_webextensions_builtin_upgrade.js]
+[test_webextensions_install.js]
+[test_webextensions_language_switch.js]
+[test_webextensions_migrate_to.js]
+support-files = data/search-migration.json
+[test_webextensions_normandy_upgrade.js]
+[test_webextensions_startup_remove.js]
+[test_webextensions_upgrade.js]
+[test_webextensions_valid.js]