summaryrefslogtreecommitdiffstats
path: root/browser/components/search/test
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/search/test')
-rw-r--r--browser/components/search/test/browser/426329.xml11
-rw-r--r--browser/components/search/test/browser/483086-1.xml10
-rw-r--r--browser/components/search/test/browser/483086-2.xml10
-rw-r--r--browser/components/search/test/browser/browser.ini176
-rw-r--r--browser/components/search/test/browser/browser_426329.js335
-rw-r--r--browser/components/search/test/browser/browser_483086.js56
-rw-r--r--browser/components/search/test/browser/browser_addKeywordSearch.js89
-rw-r--r--browser/components/search/test/browser/browser_contentContextMenu.js230
-rw-r--r--browser/components/search/test/browser/browser_contentContextMenu.xhtml22
-rw-r--r--browser/components/search/test/browser/browser_contentSearchUI.js1158
-rw-r--r--browser/components/search/test/browser/browser_contentSearchUI_default.js210
-rw-r--r--browser/components/search/test/browser/browser_contextSearchTabPosition.js94
-rw-r--r--browser/components/search/test/browser/browser_contextmenu.js249
-rw-r--r--browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js183
-rw-r--r--browser/components/search/test/browser/browser_defaultPrivate_nimbus.js155
-rw-r--r--browser/components/search/test/browser/browser_google_behavior.js215
-rw-r--r--browser/components/search/test/browser/browser_hiddenOneOffs_cleanup.js117
-rw-r--r--browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js74
-rw-r--r--browser/components/search/test/browser/browser_ime_composition.js77
-rw-r--r--browser/components/search/test/browser/browser_oneOffContextMenu.js89
-rw-r--r--browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js236
-rw-r--r--browser/components/search/test/browser/browser_private_search_perwindowpb.js84
-rw-r--r--browser/components/search/test/browser/browser_rich_suggestions.js110
-rw-r--r--browser/components/search/test/browser/browser_searchEngine_behaviors.js223
-rw-r--r--browser/components/search/test/browser/browser_search_annotation.js176
-rw-r--r--browser/components/search/test/browser/browser_search_discovery.js132
-rw-r--r--browser/components/search/test/browser/browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js159
-rw-r--r--browser/components/search/test/browser/browser_search_nimbus_reload.js55
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_abandonment.js243
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_aboutHome.js135
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_adImpression_component.js401
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_categorization_timing.js107
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_content.js204
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_engagement_cached.js199
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_engagement_cached_serp.js239
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_engagement_content.js486
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_engagement_multiple_tabs.js225
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_engagement_non_ad.js164
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_engagement_redirect.js346
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_engagement_target.js433
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_searchbar.js440
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_shopping.js149
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_sources.js497
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_sources_ads.js841
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_sources_in_content.js435
-rw-r--r--browser/components/search/test/browser/browser_search_telemetry_sources_navigation.js549
-rw-r--r--browser/components/search/test/browser/browser_searchbar_addEngine.js99
-rw-r--r--browser/components/search/test/browser/browser_searchbar_context.js246
-rw-r--r--browser/components/search/test/browser/browser_searchbar_default.js221
-rw-r--r--browser/components/search/test/browser/browser_searchbar_enter.js152
-rw-r--r--browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js644
-rw-r--r--browser/components/search/test/browser/browser_searchbar_openpopup.js794
-rw-r--r--browser/components/search/test/browser/browser_searchbar_results.js60
-rw-r--r--browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js449
-rw-r--r--browser/components/search/test/browser/browser_searchbar_widths.js33
-rw-r--r--browser/components/search/test/browser/browser_tooManyEnginesOffered.js68
-rw-r--r--browser/components/search/test/browser/browser_trending_suggestions.js189
-rw-r--r--browser/components/search/test/browser/cacheable.html12
-rw-r--r--browser/components/search/test/browser/cacheable.html^headers^1
-rw-r--r--browser/components/search/test/browser/contentSearchUI.html22
-rw-r--r--browser/components/search/test/browser/contentSearchUI.js13
-rw-r--r--browser/components/search/test/browser/discovery.html9
-rw-r--r--browser/components/search/test/browser/google_codes/browser.ini5
-rw-r--r--browser/components/search/test/browser/head.js395
-rw-r--r--browser/components/search/test/browser/mozsearch.sjs11
-rw-r--r--browser/components/search/test/browser/opensearch.html10
-rw-r--r--browser/components/search/test/browser/redirect_ad.sjs10
-rw-r--r--browser/components/search/test/browser/redirect_final.sjs9
-rw-r--r--browser/components/search/test/browser/redirect_once.sjs9
-rw-r--r--browser/components/search/test/browser/redirect_thrice.sjs9
-rw-r--r--browser/components/search/test/browser/redirect_twice.sjs9
-rw-r--r--browser/components/search/test/browser/search-engines/basic/manifest.json20
-rw-r--r--browser/components/search/test/browser/search-engines/private/manifest.json20
-rw-r--r--browser/components/search/test/browser/searchSuggestionEngine.sjs55
-rw-r--r--browser/components/search/test/browser/searchTelemetry.html11
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd.html13
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_carousel.html116
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_carousel_below_the_fold.html83
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_carousel_doubled.html182
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_carousel_first_element_non_visible.html85
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_carousel_hidden.html87
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_carousel_outer_container.html83
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_text.html112
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_components_visibility.html46
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_dataAttributes.html10
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_dataAttributes_href.html10
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_dataAttributes_none.html10
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html12
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html^headers^1
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html17
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^4
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_searchbox.html38
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_searchbox.html^headers^1
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html39
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html^headers^1
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html12
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html^headers^1
-rw-r--r--browser/components/search/test/browser/searchTelemetryAd_shopping.html15
-rw-r--r--browser/components/search/test/browser/serp.css164
-rw-r--r--browser/components/search/test/browser/slow_loading_page_with_ads.html14
-rw-r--r--browser/components/search/test/browser/slow_loading_page_with_ads.sjs21
-rw-r--r--browser/components/search/test/browser/slow_loading_page_with_ads_on_load_event.html30
-rw-r--r--browser/components/search/test/browser/telemetrySearchSuggestions.sjs9
-rw-r--r--browser/components/search/test/browser/telemetrySearchSuggestions.xml6
-rw-r--r--browser/components/search/test/browser/test.html8
-rw-r--r--browser/components/search/test/browser/testEngine.xml12
-rw-r--r--browser/components/search/test/browser/testEngine_diacritics.xml12
-rw-r--r--browser/components/search/test/browser/testEngine_dupe.xml12
-rw-r--r--browser/components/search/test/browser/testEngine_mozsearch.xml14
-rw-r--r--browser/components/search/test/browser/test_search.html1
-rw-r--r--browser/components/search/test/browser/tooManyEnginesOffered.html13
-rw-r--r--browser/components/search/test/browser/trendingSuggestionEngine.sjs51
-rw-r--r--browser/components/search/test/marionette/manifest.ini4
-rw-r--r--browser/components/search/test/marionette/test_engines_on_restart.py40
-rw-r--r--browser/components/search/test/unit/test_search_telemetry_config_validation.js82
-rw-r--r--browser/components/search/test/unit/test_urlTelemetry.js310
-rw-r--r--browser/components/search/test/unit/test_urlTelemetry_generic.js323
-rw-r--r--browser/components/search/test/unit/xpcshell.ini9
118 files changed, 16526 insertions, 0 deletions
diff --git a/browser/components/search/test/browser/426329.xml b/browser/components/search/test/browser/426329.xml
new file mode 100644
index 0000000000..b565ed7288
--- /dev/null
+++ b/browser/components/search/test/browser/426329.xml
@@ -0,0 +1,11 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
+ xmlns:moz="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>Bug 426329</ShortName>
+ <Description>426329 Search</Description>
+ <InputEncoding>utf-8</InputEncoding>
+ <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/test.html">
+ <Param name="test" value="{searchTerms}"/>
+ </Url>
+ <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/test.html</moz:SearchForm>
+</OpenSearchDescription>
diff --git a/browser/components/search/test/browser/483086-1.xml b/browser/components/search/test/browser/483086-1.xml
new file mode 100644
index 0000000000..765cd13d4f
--- /dev/null
+++ b/browser/components/search/test/browser/483086-1.xml
@@ -0,0 +1,10 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
+ xmlns:moz="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>483086a</ShortName>
+ <Description>Bug 483086 Test 1</Description>
+ <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search">
+ <Param name="test" value="{searchTerms}"/>
+ </Url>
+ <moz:SearchForm>foo://example.com</moz:SearchForm>
+</OpenSearchDescription>
diff --git a/browser/components/search/test/browser/483086-2.xml b/browser/components/search/test/browser/483086-2.xml
new file mode 100644
index 0000000000..ce952ac2e7
--- /dev/null
+++ b/browser/components/search/test/browser/483086-2.xml
@@ -0,0 +1,10 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
+ xmlns:moz="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>483086b</ShortName>
+ <Description>Bug 483086 Test 2</Description>
+ <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search">
+ <Param name="test" value="{searchTerms}"/>
+ </Url>
+ <moz:SearchForm>http://example.com</moz:SearchForm>
+</OpenSearchDescription>
diff --git a/browser/components/search/test/browser/browser.ini b/browser/components/search/test/browser/browser.ini
new file mode 100644
index 0000000000..09fcd7e50f
--- /dev/null
+++ b/browser/components/search/test/browser/browser.ini
@@ -0,0 +1,176 @@
+[DEFAULT]
+support-files =
+ mozsearch.sjs
+ test_search.html
+ 426329.xml
+ 483086-1.xml
+ 483086-2.xml
+ discovery.html
+ head.js
+ opensearch.html
+ test.html
+ testEngine.xml
+ testEngine_diacritics.xml
+ testEngine_dupe.xml
+ testEngine_mozsearch.xml
+ tooManyEnginesOffered.html
+
+[browser_426329.js]
+[browser_483086.js]
+[browser_addKeywordSearch.js]
+[browser_contentContextMenu.js]
+support-files = browser_contentContextMenu.xhtml
+[browser_contentSearchUI.js]
+support-files =
+ contentSearchUI.html
+ contentSearchUI.js
+ searchSuggestionEngine.sjs
+[browser_contentSearchUI_default.js]
+[browser_contextSearchTabPosition.js]
+[browser_contextmenu.js]
+[browser_contextmenu_whereToOpenLink.js]
+[browser_defaultPrivate_nimbus.js]
+support-files =
+ search-engines/basic/manifest.json
+ search-engines/private/manifest.json
+[browser_google_behavior.js]
+[browser_hiddenOneOffs_cleanup.js]
+[browser_hiddenOneOffs_diacritics.js]
+[browser_ime_composition.js]
+[browser_oneOffContextMenu.js]
+[browser_oneOffContextMenu_setDefault.js]
+[browser_private_search_perwindowpb.js]
+[browser_rich_suggestions.js]
+support-files =
+ trendingSuggestionEngine.sjs
+[browser_searchEngine_behaviors.js]
+[browser_search_annotation.js]
+[browser_search_discovery.js]
+[browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd.html
+[browser_search_nimbus_reload.js]
+[browser_search_telemetry_abandonment.js]
+tags = search-telemetry
+support-files =
+ searchTelemetry.html
+ searchTelemetryAd.html
+[browser_search_telemetry_aboutHome.js]
+tags = search-telemetry
+[browser_search_telemetry_adImpression_component.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_components_carousel.html
+ searchTelemetryAd_components_carousel_below_the_fold.html
+ searchTelemetryAd_components_carousel_doubled.html
+ searchTelemetryAd_components_carousel_first_element_non_visible.html
+ searchTelemetryAd_components_carousel_hidden.html
+ searchTelemetryAd_components_carousel_outer_container.html
+ searchTelemetryAd_components_text.html
+ searchTelemetryAd_components_visibility.html
+ serp.css
+[browser_search_telemetry_categorization_timing.js]
+[browser_search_telemetry_content.js]
+tags = search-telemetry
+[browser_search_telemetry_engagement_cached.js]
+tags = search-telemetry
+support-files =
+ cacheable.html
+ cacheable.html^headers^
+ searchTelemetryAd_components_text.html
+ serp.css
+[browser_search_telemetry_engagement_cached_serp.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_searchbox.html
+ searchTelemetryAd_searchbox.html^headers^
+[browser_search_telemetry_engagement_content.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_searchbox_with_content.html
+ searchTelemetryAd_searchbox_with_content.html^headers^
+ searchTelemetryAd_searchbox_with_content_redirect.html
+ searchTelemetryAd_searchbox_with_content_redirect.html^headers^
+ serp.css
+[browser_search_telemetry_engagement_multiple_tabs.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_searchbox_with_content.html
+ searchTelemetryAd_searchbox_with_content.html^headers^
+[browser_search_telemetry_engagement_non_ad.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_searchbox_with_content.html
+ searchTelemetryAd_searchbox_with_content.html^headers^
+ serp.css
+[browser_search_telemetry_engagement_redirect.js]
+tags = search-telemetry
+support-files =
+ redirect_ad.sjs
+ redirect_final.sjs
+ redirect_once.sjs
+ redirect_thrice.sjs
+ redirect_twice.sjs
+ searchTelemetryAd_components_text.html
+ searchTelemetryAd_nonAdsLink_redirect.html
+ searchTelemetryAd_nonAdsLink_redirect.html^headers^
+ searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html
+ searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^
+ serp.css
+[browser_search_telemetry_engagement_target.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_components_text.html
+ searchTelemetryAd_searchbox.html
+ searchTelemetryAd_searchbox.html^headers^
+ serp.css
+[browser_search_telemetry_searchbar.js]
+https_first_disabled = true
+tags = search-telemetry
+support-files =
+ slow_loading_page_with_ads_on_load_event.html
+ slow_loading_page_with_ads.html
+ slow_loading_page_with_ads.sjs
+ telemetrySearchSuggestions.sjs
+ telemetrySearchSuggestions.xml
+[browser_search_telemetry_shopping.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_shopping.html
+[browser_search_telemetry_sources.js]
+tags = search-telemetry
+support-files =
+ searchTelemetry.html
+ searchTelemetryAd.html
+[browser_search_telemetry_sources_ads.js]
+tags = search-telemetry
+support-files =
+ searchTelemetry.html
+ searchTelemetryAd.html
+ searchTelemetryAd_dataAttributes.html
+ searchTelemetryAd_dataAttributes_href.html
+ searchTelemetryAd_dataAttributes_none.html
+[browser_search_telemetry_sources_in_content.js]
+tags = search-telemetry
+support-files =
+ searchTelemetryAd_searchbox_with_content.html
+[browser_search_telemetry_sources_navigation.js]
+tags = search-telemetry
+support-files =
+ searchTelemetry.html
+ searchTelemetryAd.html
+[browser_searchbar_addEngine.js]
+[browser_searchbar_context.js]
+[browser_searchbar_default.js]
+[browser_searchbar_enter.js]
+[browser_searchbar_keyboard_navigation.js]
+skip-if = (os == 'win' && debug) || (os == 'linux' && asan || debug || tsan) # Bug 1792718
+[browser_searchbar_openpopup.js]
+[browser_searchbar_results.js]
+[browser_searchbar_smallpanel_keyboard_navigation.js]
+[browser_searchbar_widths.js]
+[browser_tooManyEnginesOffered.js]
+[browser_trending_suggestions.js]
+support-files =
+ trendingSuggestionEngine.sjs
diff --git a/browser/components/search/test/browser/browser_426329.js b/browser/components/search/test/browser/browser_426329.js
new file mode 100644
index 0000000000..85b7fca2ee
--- /dev/null
+++ b/browser/components/search/test/browser/browser_426329.js
@@ -0,0 +1,335 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+ChromeUtils.defineESModuleGetters(this, {
+ FormHistoryTestUtils:
+ "resource://testing-common/FormHistoryTestUtils.sys.mjs",
+});
+
+function expectedURL(aSearchTerms) {
+ const ENGINE_HTML_BASE =
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/test.html";
+ let searchArg = Services.textToSubURI.ConvertAndEscape("utf-8", aSearchTerms);
+ return ENGINE_HTML_BASE + "?test=" + searchArg;
+}
+
+function simulateClick(aEvent, aTarget) {
+ let event = document.createEvent("MouseEvent");
+ let ctrlKeyArg = aEvent.ctrlKey || false;
+ let altKeyArg = aEvent.altKey || false;
+ let shiftKeyArg = aEvent.shiftKey || false;
+ let metaKeyArg = aEvent.metaKey || false;
+ let buttonArg = aEvent.button || 0;
+ event.initMouseEvent(
+ "click",
+ true,
+ true,
+ window,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ ctrlKeyArg,
+ altKeyArg,
+ shiftKeyArg,
+ metaKeyArg,
+ buttonArg,
+ null
+ );
+ aTarget.dispatchEvent(event);
+}
+
+// modified from toolkit/components/satchel/test/test_form_autocomplete.html
+function checkMenuEntries(expectedValues) {
+ let actualValues = getMenuEntries();
+ is(
+ actualValues.length,
+ expectedValues.length,
+ "Checking length of expected menu"
+ );
+ for (let i = 0; i < expectedValues.length; i++) {
+ is(actualValues[i], expectedValues[i], "Checking menu entry #" + i);
+ }
+}
+
+function getMenuEntries() {
+ // Could perhaps pull values directly from the controller, but it seems
+ // more reliable to test the values that are actually in the richlistbox?
+ return Array.from(searchBar.textbox.popup.richlistbox.itemChildren, item =>
+ item.getAttribute("ac-value")
+ );
+}
+
+var searchBar;
+var searchButton;
+var searchEntries = ["test"];
+function promiseSetEngine() {
+ return new Promise(resolve => {
+ let ss = Services.search;
+
+ function observer(aSub, aTopic, aData) {
+ switch (aData) {
+ case "engine-added":
+ let engine = ss.getEngineByName("Bug 426329");
+ ok(engine, "Engine was added.");
+ ss.defaultEngine = engine;
+ break;
+ case "engine-default":
+ ok(ss.defaultEngine.name == "Bug 426329", "defaultEngine set");
+ searchBar = BrowserSearch.searchBar;
+ searchButton = searchBar.querySelector(".search-go-button");
+ ok(searchButton, "got search-go-button");
+
+ Services.obs.removeObserver(
+ observer,
+ "browser-search-engine-modified"
+ );
+ resolve();
+ break;
+ }
+ }
+
+ Services.obs.addObserver(observer, "browser-search-engine-modified");
+ ss.addOpenSearchEngine(
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/426329.xml",
+ "data:image/x-icon,%00"
+ );
+ });
+}
+
+function promiseRemoveEngine() {
+ return new Promise(resolve => {
+ let ss = Services.search;
+
+ function observer(aSub, aTopic, aData) {
+ if (aData == "engine-removed") {
+ Services.obs.removeObserver(observer, "browser-search-engine-modified");
+ resolve();
+ }
+ }
+
+ Services.obs.addObserver(observer, "browser-search-engine-modified");
+ let engine = ss.getEngineByName("Bug 426329");
+ ss.removeEngine(engine);
+ });
+}
+
+var preSelectedBrowser;
+var preTabNo;
+async function prepareTest() {
+ await Services.search.init();
+
+ preSelectedBrowser = gBrowser.selectedBrowser;
+ preTabNo = gBrowser.tabs.length;
+ searchBar = BrowserSearch.searchBar;
+
+ await SimpleTest.promiseFocus();
+
+ if (document.activeElement == searchBar) {
+ return;
+ }
+
+ let focusPromise = BrowserTestUtils.waitForEvent(searchBar.textbox, "focus");
+ gURLBar.focus();
+ searchBar.focus();
+ await focusPromise;
+}
+
+add_task(async function testSetup() {
+ await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function testSetupEngine() {
+ await promiseSetEngine();
+ searchBar.value = "test";
+});
+
+add_task(async function testReturn() {
+ await prepareTest();
+ EventUtils.synthesizeKey("KEY_Enter");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ is(gBrowser.tabs.length, preTabNo, "Return key did not open new tab");
+ is(
+ gBrowser.currentURI.spec,
+ expectedURL(searchBar.value),
+ "testReturn opened correct search page"
+ );
+});
+
+add_task(async function testAltReturn() {
+ await prepareTest();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, () => {
+ EventUtils.synthesizeKey("KEY_Enter", { altKey: true });
+ });
+
+ is(gBrowser.tabs.length, preTabNo + 1, "Alt+Return key added new tab");
+ is(
+ gBrowser.currentURI.spec,
+ expectedURL(searchBar.value),
+ "testAltReturn opened correct search page"
+ );
+});
+
+add_task(async function testAltGrReturn() {
+ await prepareTest();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, () => {
+ EventUtils.synthesizeKey("KEY_Enter", { altGraphKey: true });
+ });
+
+ is(gBrowser.tabs.length, preTabNo + 1, "AltGr+Return key added new tab");
+ is(
+ gBrowser.currentURI.spec,
+ expectedURL(searchBar.value),
+ "testAltGrReturn opened correct search page"
+ );
+});
+
+// Shift key has no effect for now, so skip it
+add_task(async function testShiftAltReturn() {
+ /*
+ yield* prepareTest();
+
+ let url = expectedURL(searchBar.value);
+
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, url);
+ EventUtils.synthesizeKey("VK_RETURN", { shiftKey: true, altKey: true });
+ yield newTabPromise;
+
+ is(gBrowser.tabs.length, preTabNo + 1, "Shift+Alt+Return key added new tab");
+ is(gBrowser.currentURI.spec, url, "testShiftAltReturn opened correct search page");
+ */
+});
+
+add_task(async function testLeftClick() {
+ await prepareTest();
+ simulateClick({ button: 0 }, searchButton);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ is(gBrowser.tabs.length, preTabNo, "LeftClick did not open new tab");
+ is(
+ gBrowser.currentURI.spec,
+ expectedURL(searchBar.value),
+ "testLeftClick opened correct search page"
+ );
+});
+
+add_task(async function testMiddleClick() {
+ await prepareTest();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, () => {
+ simulateClick({ button: 1 }, searchButton);
+ });
+ is(gBrowser.tabs.length, preTabNo + 1, "MiddleClick added new tab");
+ is(
+ gBrowser.currentURI.spec,
+ expectedURL(searchBar.value),
+ "testMiddleClick opened correct search page"
+ );
+});
+
+add_task(async function testShiftMiddleClick() {
+ await prepareTest();
+
+ let url = expectedURL(searchBar.value);
+
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, url);
+ simulateClick({ button: 1, shiftKey: true }, searchButton);
+ let newTab = await newTabPromise;
+
+ is(gBrowser.tabs.length, preTabNo + 1, "Shift+MiddleClick added new tab");
+ is(
+ newTab.linkedBrowser.currentURI.spec,
+ url,
+ "testShiftMiddleClick opened correct search page"
+ );
+});
+
+add_task(async function testRightClick() {
+ preTabNo = gBrowser.tabs.length;
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank", {
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
+ });
+ await new Promise(resolve => {
+ setTimeout(function () {
+ is(gBrowser.tabs.length, preTabNo, "RightClick did not open new tab");
+ is(gBrowser.currentURI.spec, "about:blank", "RightClick did nothing");
+ resolve();
+ }, 5000);
+ simulateClick({ button: 2 }, searchButton);
+ });
+ // The click in the searchbox focuses it, which opens the suggestion
+ // panel. Clean up after ourselves.
+ searchBar.textbox.popup.hidePopup();
+});
+
+add_task(async function testSearchHistory() {
+ let textbox = searchBar._textbox;
+ for (let i = 0; i < searchEntries.length; i++) {
+ let count = await FormHistoryTestUtils.count(
+ textbox.getAttribute("autocompletesearchparam"),
+ { value: searchEntries[i], source: "Bug 426329" }
+ );
+ ok(count > 0, "form history entry '" + searchEntries[i] + "' should exist");
+ }
+});
+
+add_task(async function testAutocomplete() {
+ let popup = searchBar.textbox.popup;
+ let popupShownPromise = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ searchBar.textbox.showHistoryPopup();
+ await popupShownPromise;
+ checkMenuEntries(searchEntries);
+ searchBar.textbox.closePopup();
+});
+
+add_task(async function testClearHistory() {
+ // Open the textbox context menu to trigger controller attachment.
+ let textbox = searchBar.textbox;
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ false,
+ event => event.target.classList.contains("textbox-contextmenu")
+ );
+ EventUtils.synthesizeMouseAtCenter(textbox, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await popupShownPromise;
+ // Close the context menu.
+ let contextMenu = document.querySelector(".textbox-contextmenu");
+ contextMenu.hidePopup();
+
+ let menuitem = searchBar._menupopup.querySelector(".searchbar-clear-history");
+ ok(!menuitem.disabled, "Clear history menuitem enabled");
+
+ let historyCleared = promiseObserver("satchel-storage-changed");
+ searchBar._menupopup.activateItem(menuitem);
+ await historyCleared;
+ let count = await FormHistoryTestUtils.count(
+ textbox.getAttribute("autocompletesearchparam")
+ );
+ ok(count == 0, "History cleared");
+});
+
+add_task(async function asyncCleanup() {
+ searchBar.value = "";
+ while (gBrowser.tabs.length != 1) {
+ gBrowser.removeTab(gBrowser.tabs[0], { animate: false });
+ }
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank", {
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
+ });
+ await promiseRemoveEngine();
+});
+
+function promiseObserver(topic) {
+ return new Promise(resolve => {
+ let obs = (aSubject, aTopic, aData) => {
+ Services.obs.removeObserver(obs, aTopic);
+ resolve(aSubject);
+ };
+ Services.obs.addObserver(obs, topic);
+ });
+}
diff --git a/browser/components/search/test/browser/browser_483086.js b/browser/components/search/test/browser/browser_483086.js
new file mode 100644
index 0000000000..9a487c403b
--- /dev/null
+++ b/browser/components/search/test/browser/browser_483086.js
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+var gSS = Services.search;
+
+function test() {
+ waitForExplicitFinish();
+
+ function observer(aSubject, aTopic, aData) {
+ switch (aData) {
+ case "engine-added":
+ let engine = gSS.getEngineByName("483086a");
+ ok(engine, "Test engine 1 installed");
+ isnot(
+ engine.searchForm,
+ "foo://example.com",
+ "Invalid SearchForm URL dropped"
+ );
+ gSS.removeEngine(engine);
+ break;
+ case "engine-removed":
+ Services.obs.removeObserver(observer, "browser-search-engine-modified");
+ test2();
+ break;
+ }
+ }
+
+ Services.obs.addObserver(observer, "browser-search-engine-modified");
+ gSS.addOpenSearchEngine(
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/483086-1.xml",
+ "data:image/x-icon;%00"
+ );
+}
+
+function test2() {
+ function observer(aSubject, aTopic, aData) {
+ switch (aData) {
+ case "engine-added":
+ let engine = gSS.getEngineByName("483086b");
+ ok(engine, "Test engine 2 installed");
+ is(engine.searchForm, "http://example.com", "SearchForm is correct");
+ gSS.removeEngine(engine);
+ break;
+ case "engine-removed":
+ Services.obs.removeObserver(observer, "browser-search-engine-modified");
+ finish();
+ break;
+ }
+ }
+
+ Services.obs.addObserver(observer, "browser-search-engine-modified");
+ gSS.addOpenSearchEngine(
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/483086-2.xml",
+ "data:image/x-icon;%00"
+ );
+}
diff --git a/browser/components/search/test/browser/browser_addKeywordSearch.js b/browser/components/search/test/browser/browser_addKeywordSearch.js
new file mode 100644
index 0000000000..6de653fd89
--- /dev/null
+++ b/browser/components/search/test/browser/browser_addKeywordSearch.js
@@ -0,0 +1,89 @@
+var testData = [
+ { desc: "No path", action: "http://example.com/", param: "q" },
+ {
+ desc: "With path",
+ action: "http://example.com/new-path-here/",
+ param: "q",
+ },
+ { desc: "No action", action: "", param: "q" },
+ {
+ desc: "With Query String",
+ action: "http://example.com/search?oe=utf-8",
+ param: "q",
+ },
+];
+
+add_task(async function () {
+ const TEST_URL =
+ "http://example.org/browser/browser/components/search/test/browser/test.html";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+ let count = 0;
+ for (let method of ["GET", "POST"]) {
+ for (let { desc, action, param } of testData) {
+ info(`Running ${method} keyword test '${desc}'`);
+ let id = `keyword-form-${count++}`;
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ action, param, method, id }],
+ async function (args) {
+ let doc = content.document;
+ let form = doc.createElement("form");
+ form.id = args.id;
+ form.method = args.method;
+ form.action = args.action;
+ let element = doc.createElement("input");
+ element.setAttribute("type", "text");
+ element.setAttribute("name", args.param);
+ form.appendChild(element);
+ doc.body.appendChild(form);
+ }
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ `#${id} > input`,
+ { type: "contextmenu", button: 2 },
+ tab.linkedBrowser
+ );
+ await contextMenuPromise;
+ let url = action || tab.linkedBrowser.currentURI.spec;
+ let actor = gContextMenu.actor;
+
+ let data = await actor.getSearchFieldBookmarkData(
+ gContextMenu.targetIdentifier
+ );
+ if (method == "GET") {
+ ok(
+ data.spec.endsWith(`${param}=%s`),
+ `Check expected url for field named ${param} and action ${action}`
+ );
+ } else {
+ is(
+ data.spec,
+ url,
+ `Check expected url for field named ${param} and action ${action}`
+ );
+ is(
+ data.postData,
+ `${param}%3D%25s`,
+ `Check expected POST data for field named ${param} and action ${action}`
+ );
+ }
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+ }
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_contentContextMenu.js b/browser/components/search/test/browser/browser_contentContextMenu.js
new file mode 100644
index 0000000000..684428821e
--- /dev/null
+++ b/browser/components/search/test/browser/browser_contentContextMenu.js
@@ -0,0 +1,230 @@
+/* Make sure context menu includes option to search hyperlink text on search
+ * engine.
+ */
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.separatePrivateDefault", true],
+ ["browser.search.separatePrivateDefault.ui.enabled", true],
+ ],
+ });
+
+ const url =
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/browser_contentContextMenu.xhtml";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ const ellipsis = "\u2026";
+
+ let contentAreaContextMenu = document.getElementById(
+ "contentAreaContextMenu"
+ );
+
+ const originalPrivateDefault = await Services.search.getDefaultPrivate();
+ let otherPrivateDefault;
+ for (let engine of await Services.search.getVisibleEngines()) {
+ if (engine.name != originalPrivateDefault.name) {
+ otherPrivateDefault = engine;
+ break;
+ }
+ }
+
+ // Tests if the "Search <engine> for '<some terms>'" context menu item is
+ // shown for the given query string of an element. Tests to make sure label
+ // includes the proper search terms.
+ //
+ // Each test:
+ //
+ // id: The id of the element to test.
+ // isSelected: Flag to enable selecting (text highlight) the contents of the
+ // element.
+ // shouldBeShown: The display state of the menu item.
+ // expectedLabelContents: The menu item label should contain a portion of
+ // this string. Will only be tested if shouldBeShown
+ // is true.
+ // shouldPrivateBeShown: The display state of the Private Window menu item.
+ // expectedPrivateLabelContents: The menu item label for the Private Window
+ // should contain a portion of this string.
+ // Will only be tested if shouldPrivateBeShown
+ // is true.
+ let tests = [
+ {
+ id: "link",
+ isSelected: true,
+ shouldBeShown: true,
+ expectedLabelContents: "I'm a link!",
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search in",
+ },
+ {
+ id: "link",
+ isSelected: false,
+ shouldBeShown: true,
+ expectedLabelContents: "I'm a link!",
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search in",
+ },
+ {
+ id: "longLink",
+ isSelected: true,
+ shouldBeShown: true,
+ expectedLabelContents: "I'm a really lo" + ellipsis,
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search in",
+ },
+ {
+ id: "longLink",
+ isSelected: false,
+ shouldBeShown: true,
+ expectedLabelContents: "I'm a really lo" + ellipsis,
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search in",
+ },
+ {
+ id: "plainText",
+ isSelected: true,
+ shouldBeShown: true,
+ expectedLabelContents: "Right clicking " + ellipsis,
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search in",
+ },
+ {
+ id: "plainText",
+ isSelected: false,
+ shouldBeShown: false,
+ shouldPrivateBeShown: false,
+ },
+ {
+ id: "mixedContent",
+ isSelected: true,
+ shouldBeShown: true,
+ expectedLabelContents: "I'm some text, " + ellipsis,
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search in",
+ },
+ {
+ id: "mixedContent",
+ isSelected: false,
+ shouldBeShown: false,
+ shouldPrivateBeShown: false,
+ },
+ {
+ id: "partialLink",
+ isSelected: true,
+ shouldBeShown: true,
+ expectedLabelContents: "link selection",
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search in",
+ },
+ {
+ id: "partialLink",
+ isSelected: false,
+ shouldBeShown: true,
+ expectedLabelContents: "A partial link " + ellipsis,
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search with " + otherPrivateDefault.name,
+ changePrivateDefaultEngine: true,
+ },
+ {
+ id: "surrogatePair",
+ isSelected: true,
+ shouldBeShown: true,
+ expectedLabelContents: "This character\uD83D\uDD25" + ellipsis,
+ shouldPrivateBeShown: true,
+ expectedPrivateLabelContents: "Search with " + otherPrivateDefault.name,
+ changePrivateDefaultEngine: true,
+ },
+ ];
+
+ for (let test of tests) {
+ if (test.changePrivateDefaultEngine) {
+ await Services.search.setDefaultPrivate(
+ otherPrivateDefault,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ selectElement: test.isSelected ? test.id : null }],
+ async function (arg) {
+ let selection = content.getSelection();
+ selection.removeAllRanges();
+
+ if (arg.selectElement) {
+ selection.selectAllChildren(
+ content.document.getElementById(arg.selectElement)
+ );
+ }
+ }
+ );
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + test.id,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShownPromise;
+
+ let menuItem = document.getElementById("context-searchselect");
+ is(
+ menuItem.hidden,
+ !test.shouldBeShown,
+ "search context menu item is shown for '#" +
+ test.id +
+ "' and selected is '" +
+ test.isSelected +
+ "'"
+ );
+
+ if (test.shouldBeShown) {
+ ok(
+ menuItem.label.includes(test.expectedLabelContents),
+ "Menu item text '" +
+ menuItem.label +
+ "' contains the correct search terms '" +
+ test.expectedLabelContents +
+ "'"
+ );
+ }
+
+ menuItem = document.getElementById("context-searchselect-private");
+ is(
+ menuItem.hidden,
+ !test.shouldPrivateBeShown,
+ "private search context menu item is shown for '#" + test.id + "' "
+ );
+
+ if (test.shouldPrivateBeShown) {
+ ok(
+ menuItem.label.includes(test.expectedPrivateLabelContents),
+ "Menu item text '" +
+ menuItem.label +
+ "' contains the correct search terms '" +
+ test.expectedPrivateLabelContents +
+ "'"
+ );
+ }
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ contentAreaContextMenu.hidePopup();
+ await popupHiddenPromise;
+
+ if (test.changePrivateDefaultEngine) {
+ await Services.search.setDefaultPrivate(
+ originalPrivateDefault,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ }
+ }
+
+ // Cleanup.
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/components/search/test/browser/browser_contentContextMenu.xhtml b/browser/components/search/test/browser/browser_contentContextMenu.xhtml
new file mode 100644
index 0000000000..16e32eb8ac
--- /dev/null
+++ b/browser/components/search/test/browser/browser_contentContextMenu.xhtml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <body>
+ <a href="http://mozilla.org" id="link">I'm a link!</a>
+ <br/>
+ <a href="http://mozilla.org" id="longLink">I'm a really long link and I should be truncated.</a>
+ <br/>
+ <span id="plainText">
+ Right clicking me when I'm selected should show the menu item.
+ </span>
+ <br/>
+ <span id="mixedContent">
+ I'm some text, and <a href="http://mozilla.org">I'm a link!</a>
+ </span>
+ <br/>
+ <a href="http://mozilla.org">A partial <span id="partialLink">link selection</span></a>
+ <br/>
+ <span id="surrogatePair">
+ This character🔥 shouldn't be truncated.
+ </span>
+ </body>
+</html>
diff --git a/browser/components/search/test/browser/browser_contentSearchUI.js b/browser/components/search/test/browser/browser_contentSearchUI.js
new file mode 100644
index 0000000000..9196b1355c
--- /dev/null
+++ b/browser/components/search/test/browser/browser_contentSearchUI.js
@@ -0,0 +1,1158 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_PAGE_BASENAME = "contentSearchUI.html";
+
+const TEST_ENGINE1 = {
+ name: "searchSuggestionEngine1",
+ id: "other-searchSuggestionEngine1",
+ loadPath: "[addon]searchsuggestionengine1@tests.mozilla.org",
+};
+const TEST_ENGINE2 = {
+ name: "searchSuggestionEngine2",
+ id: "other-searchSuggestionEngine2",
+ loadPath: "[addon]searchsuggestionengine2@tests.mozilla.org",
+};
+
+const TEST_MSG = "ContentSearchUIControllerTest";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContentSearch: "resource:///actors/ContentSearchParent.sys.mjs",
+ FormHistoryTestUtils:
+ "resource://testing-common/FormHistoryTestUtils.sys.mjs",
+ SearchSuggestionController:
+ "resource://gre/modules/SearchSuggestionController.sys.mjs",
+});
+
+const pageURL = getRootDirectory(gTestPath) + TEST_PAGE_BASENAME;
+BrowserTestUtils.registerAboutPage(
+ registerCleanupFunction,
+ "test-about-content-search-ui",
+ pageURL,
+ Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT |
+ Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD |
+ Ci.nsIAboutModule.ALLOW_SCRIPT |
+ Ci.nsIAboutModule.URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS
+);
+
+requestLongerTimeout(2);
+
+function waitForSuggestions() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () =>
+ ContentTaskUtils.waitForCondition(
+ () =>
+ Cu.waiveXrays(content).gController.input.getAttribute(
+ "aria-expanded"
+ ) == "true",
+ "Waiting for suggestions",
+ 200 // Increased interval to support long textruns.
+ )
+ );
+}
+
+async function waitForSearch() {
+ await BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "ContentSearchClient",
+ true,
+ event => {
+ if (event.detail.type == "Search") {
+ event.target._eventDetail = event.detail.data;
+ return true;
+ }
+ return false;
+ },
+ true
+ );
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let eventDetail = content._eventDetail;
+ delete content._eventDetail;
+ return eventDetail;
+ });
+}
+
+async function waitForSearchSettings() {
+ await BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "ContentSearchClient",
+ true,
+ event => {
+ if (event.detail.type == "ManageEngines") {
+ event.target._eventDetail = event.detail.data;
+ return true;
+ }
+ return false;
+ },
+ true
+ );
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let eventDetail = content._eventDetail;
+ delete content._eventDetail;
+ return eventDetail;
+ });
+}
+
+function getCurrentState() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let controller = Cu.waiveXrays(content).gController;
+ let state = {
+ selectedIndex: controller.selectedIndex,
+ selectedButtonIndex: controller.selectedButtonIndex,
+ numSuggestions: controller._table.hidden ? 0 : controller.numSuggestions,
+ suggestionAtIndex: [],
+ isFormHistorySuggestionAtIndex: [],
+
+ tableHidden: controller._table.hidden,
+
+ inputValue: controller.input.value,
+ ariaExpanded: controller.input.getAttribute("aria-expanded"),
+ };
+
+ if (state.numSuggestions) {
+ for (let i = 0; i < controller.numSuggestions; i++) {
+ state.suggestionAtIndex.push(controller.suggestionAtIndex(i));
+ state.isFormHistorySuggestionAtIndex.push(
+ controller.isFormHistorySuggestionAtIndex(i)
+ );
+ }
+ }
+
+ return state;
+ });
+}
+
+async function msg(type, data = null) {
+ switch (type) {
+ case "reset":
+ // Reset both the input and suggestions by select all + delete. If there was
+ // no text entered, this won't have any effect, so also escape to ensure the
+ // suggestions table is closed.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ Cu.waiveXrays(content).gController.input.focus();
+ EventUtils.synthesizeKey("a", { accelKey: true }, content);
+ EventUtils.synthesizeKey("KEY_Delete", {}, content);
+ EventUtils.synthesizeKey("KEY_Escape", {}, content);
+ });
+ break;
+
+ case "key": {
+ let keyName = typeof data == "string" ? data : data.key;
+ await BrowserTestUtils.synthesizeKey(
+ keyName,
+ data.modifiers || {},
+ gBrowser.selectedBrowser
+ );
+ if (data?.waitForSuggestions) {
+ await waitForSuggestions();
+ }
+ break;
+ }
+ case "text": {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [data.value],
+ text => {
+ Cu.waiveXrays(content).gController.input.value = text.substring(
+ 0,
+ text.length - 1
+ );
+ EventUtils.synthesizeKey(
+ text.substring(text.length - 1),
+ {},
+ content
+ );
+ }
+ );
+ if (data?.waitForSuggestions) {
+ await waitForSuggestions();
+ }
+ break;
+ }
+ case "startComposition":
+ await BrowserTestUtils.synthesizeComposition(
+ "compositionstart",
+ gBrowser.selectedBrowser
+ );
+ break;
+ case "changeComposition": {
+ await BrowserTestUtils.synthesizeCompositionChange(
+ {
+ composition: {
+ string: data.data,
+ clauses: [
+ {
+ length: data.length,
+ attr: Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE,
+ },
+ ],
+ },
+ caret: { start: data.length, length: 0 },
+ },
+ gBrowser.selectedBrowser
+ );
+ if (data?.waitForSuggestions) {
+ await waitForSuggestions();
+ }
+ break;
+ }
+ case "commitComposition":
+ await BrowserTestUtils.synthesizeComposition(
+ "compositioncommitasis",
+ gBrowser.selectedBrowser
+ );
+ break;
+ case "mousemove":
+ case "click": {
+ let event;
+ let index;
+ if (type == "mousemove") {
+ event = {
+ type: "mousemove",
+ clickcount: 0,
+ };
+ index = data;
+ } else {
+ event = data.modifiers || null;
+ index = data.eltIdx;
+ }
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [type, event, index],
+ (eventType, eventArgs, itemIndex) => {
+ let controller = Cu.waiveXrays(content).gController;
+ return new Promise(resolve => {
+ let row;
+ if (itemIndex == -1) {
+ row = controller._table.firstChild;
+ } else {
+ let allElts = [
+ ...controller._suggestionsList.children,
+ ...controller._oneOffButtons,
+ content.document.getElementById("contentSearchSettingsButton"),
+ ];
+ row = allElts[itemIndex];
+ }
+ row.addEventListener(eventType, () => resolve(), { once: true });
+ EventUtils.synthesizeMouseAtCenter(row, eventArgs, content);
+ });
+ }
+ );
+ break;
+ }
+ }
+
+ return getCurrentState();
+}
+
+/**
+ * Focusses the in-content search bar.
+ *
+ * @returns {Promise}
+ * A promise that is resolved once the focus is complete.
+ */
+function focusContentSearchBar() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ Cu.waiveXrays(content).input.focus();
+ });
+}
+
+let extension1;
+let extension2;
+
+add_setup(async function () {
+ let originalOnMessageSearch = ContentSearch._onMessageSearch;
+ let originalOnMessageManageEngines = ContentSearch._onMessageManageEngines;
+
+ ContentSearch._onMessageSearch = () => {};
+ ContentSearch._onMessageManageEngines = () => {};
+
+ let currentEngines = await Services.search.getVisibleEngines();
+
+ extension1 = await SearchTestUtils.installSearchExtension(
+ {
+ name: TEST_ENGINE1.name,
+ suggest_url:
+ "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs",
+ suggest_url_get_params: "query={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+ extension2 = await SearchTestUtils.installSearchExtension({
+ name: TEST_ENGINE2.name,
+ suggest_url:
+ "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs",
+ suggest_url_get_params: "query={searchTerms}",
+ });
+
+ for (let engine of currentEngines) {
+ await Services.search.removeEngine(engine);
+ }
+
+ registerCleanupFunction(async () => {
+ ContentSearch._onMessageSearch = originalOnMessageSearch;
+ ContentSearch._onMessageManageEngines = originalOnMessageManageEngines;
+ });
+
+ await promiseTab();
+});
+
+add_task(async function emptyInput() {
+ await focusContentSearchBar();
+
+ let state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("key", "VK_BACK_SPACE");
+ checkState(state, "", [], -1);
+
+ await msg("reset");
+});
+
+add_task(async function blur() {
+ await focusContentSearchBar();
+
+ let state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ Cu.waiveXrays(content).gController.input.blur();
+ });
+ state = await getCurrentState();
+ checkState(state, "x", [], -1);
+
+ await msg("reset");
+});
+
+add_task(async function upDownKeys() {
+ await focusContentSearchBar();
+
+ let state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ // Cycle down the suggestions starting from no selection.
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "x", ["xfoo", "xbar"], 2);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "x", ["xfoo", "xbar"], 3);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ // Cycle up starting from no selection.
+ state = await msg("key", "VK_UP");
+ checkState(state, "x", ["xfoo", "xbar"], 3);
+
+ state = await msg("key", "VK_UP");
+ checkState(state, "x", ["xfoo", "xbar"], 2);
+
+ state = await msg("key", "VK_UP");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+ state = await msg("key", "VK_UP");
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+ state = await msg("key", "VK_UP");
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ await msg("reset");
+});
+
+add_task(async function rightLeftKeys() {
+ await focusContentSearchBar();
+
+ let state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("key", "VK_LEFT");
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("key", "VK_LEFT");
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("key", "VK_RIGHT");
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("key", "VK_RIGHT");
+ checkState(state, "x", [], -1);
+
+ state = await msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+ // This should make the xfoo suggestion sticky. To make sure it sticks,
+ // trigger suggestions again and cycle through them by pressing Down until
+ // nothing is selected again.
+ state = await msg("key", "VK_RIGHT");
+ checkState(state, "xfoo", [], -1);
+
+ state = await msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+ checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoofoo", ["xfoofoo", "xfoobar"], 0);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoobar", ["xfoofoo", "xfoobar"], 1);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoo", ["xfoofoo", "xfoobar"], 2);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoo", ["xfoofoo", "xfoobar"], 3);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1);
+
+ await msg("reset");
+});
+
+add_task(async function tabKey() {
+ await focusContentSearchBar();
+ await msg("key", { key: "x", waitForSuggestions: true });
+
+ let state = await msg("key", "VK_TAB");
+ checkState(state, "x", ["xfoo", "xbar"], 2);
+
+ state = await msg("key", "VK_TAB");
+ checkState(state, "x", ["xfoo", "xbar"], 3);
+
+ state = await msg("key", { key: "VK_TAB", modifiers: { shiftKey: true } });
+ checkState(state, "x", ["xfoo", "xbar"], 2);
+
+ state = await msg("key", { key: "VK_TAB", modifiers: { shiftKey: true } });
+ checkState(state, "x", [], -1);
+
+ await focusContentSearchBar();
+
+ await msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+
+ for (let i = 0; i < 3; ++i) {
+ state = await msg("key", "VK_TAB");
+ }
+ checkState(state, "x", [], -1);
+
+ await focusContentSearchBar();
+
+ await msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0);
+
+ state = await msg("key", "VK_TAB");
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0, 0);
+
+ state = await msg("key", "VK_TAB");
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0, 1);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
+
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "x", ["xfoo", "xbar"], 2);
+
+ state = await msg("key", "VK_UP");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+ state = await msg("key", "VK_TAB");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
+
+ state = await msg("key", "VK_TAB");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
+
+ state = await msg("key", "VK_TAB");
+ checkState(state, "xbar", [], -1);
+
+ await msg("reset");
+});
+
+add_task(async function cycleSuggestions() {
+ await focusContentSearchBar();
+ await msg("key", { key: "x", waitForSuggestions: true });
+
+ let cycle = async function (aSelectedButtonIndex) {
+ let modifiers = {
+ shiftKey: true,
+ accelKey: true,
+ };
+
+ let state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex);
+
+ state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, aSelectedButtonIndex);
+
+ state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex);
+
+ state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, aSelectedButtonIndex);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex);
+ };
+
+ await cycle();
+
+ // Repeat with a one-off selected.
+ let state = await msg("key", "VK_TAB");
+ checkState(state, "x", ["xfoo", "xbar"], 2);
+ await cycle(0);
+
+ // Repeat with the settings button selected.
+ state = await msg("key", "VK_TAB");
+ checkState(state, "x", ["xfoo", "xbar"], 3);
+ await cycle(1);
+
+ await msg("reset");
+});
+
+add_task(async function cycleOneOffs() {
+ await focusContentSearchBar();
+ await msg("key", { key: "x", waitForSuggestions: true });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let btn =
+ Cu.waiveXrays(content).gController._oneOffButtons[
+ Cu.waiveXrays(content).gController._oneOffButtons.length - 1
+ ];
+ let newBtn = btn.cloneNode(true);
+ btn.parentNode.appendChild(newBtn);
+ Cu.waiveXrays(content).gController._oneOffButtons.push(newBtn);
+ });
+
+ let state = await msg("key", "VK_DOWN");
+ state = await msg("key", "VK_DOWN");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+ let modifiers = {
+ altKey: true,
+ };
+
+ state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
+
+ state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
+
+ state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1);
+
+ // If the settings button is selected, pressing alt+up/down should select the
+ // last/first one-off respectively (and deselect the settings button).
+ await msg("key", "VK_TAB");
+ await msg("key", "VK_TAB");
+ state = await msg("key", "VK_TAB"); // Settings button selected.
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 2);
+
+ state = await msg("key", { key: "VK_UP", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 1);
+
+ state = await msg("key", "VK_TAB");
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 2);
+
+ state = await msg("key", { key: "VK_DOWN", modifiers });
+ checkState(state, "xbar", ["xfoo", "xbar"], 1, 0);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ Cu.waiveXrays(content).gController._oneOffButtons.pop().remove();
+ });
+ await msg("reset");
+});
+
+add_task(async function mouse() {
+ await focusContentSearchBar();
+
+ let state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("mousemove", 0);
+ checkState(state, "x", ["xfoo", "xbar"], 0);
+
+ state = await msg("mousemove", 1);
+ checkState(state, "x", ["xfoo", "xbar"], 1);
+
+ state = await msg("mousemove", 2);
+ checkState(state, "x", ["xfoo", "xbar"], 2, 0);
+
+ state = await msg("mousemove", 3);
+ checkState(state, "x", ["xfoo", "xbar"], 3, 1);
+
+ state = await msg("mousemove", -1);
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ await msg("reset");
+ await focusContentSearchBar();
+
+ state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ state = await msg("mousemove", 0);
+ checkState(state, "x", ["xfoo", "xbar"], 0);
+
+ state = await msg("mousemove", 2);
+ checkState(state, "x", ["xfoo", "xbar"], 2, 0);
+
+ state = await msg("mousemove", -1);
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ await msg("reset");
+});
+
+add_task(async function formHistory() {
+ await focusContentSearchBar();
+
+ // Type an X and add it to form history.
+ let state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+ // Wait for Satchel to say it's been added to form history.
+ let observePromise = new Promise(resolve => {
+ Services.obs.addObserver(function onAdd(subj, topic, data) {
+ if (data == "formhistory-add") {
+ Services.obs.removeObserver(onAdd, "satchel-storage-changed");
+ executeSoon(resolve);
+ }
+ }, "satchel-storage-changed");
+ });
+
+ await FormHistoryTestUtils.clear("searchbar-history");
+ let entry = await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ return Cu.waiveXrays(content).gController.addInputValueToFormHistory();
+ });
+ await observePromise;
+ Assert.greater(
+ await FormHistoryTestUtils.count("searchbar-history", {
+ source: entry.source,
+ }),
+ 0
+ );
+
+ // Reset the input.
+ state = await msg("reset");
+ checkState(state, "", [], -1);
+
+ // Type an X again. The form history entry should appear.
+ state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(
+ state,
+ "x",
+ [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
+ -1
+ );
+
+ // Select the form history entry and delete it.
+ state = await msg("key", "VK_DOWN");
+ checkState(
+ state,
+ "x",
+ [{ str: "x", type: "formHistory" }, "xfoo", "xbar"],
+ 0
+ );
+
+ // Wait for Satchel.
+ observePromise = new Promise(resolve => {
+ Services.obs.addObserver(function onRemove(subj, topic, data) {
+ if (data == "formhistory-remove") {
+ Services.obs.removeObserver(onRemove, "satchel-storage-changed");
+ executeSoon(resolve);
+ }
+ }, "satchel-storage-changed");
+ });
+ state = await msg("key", "VK_DELETE");
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ await observePromise;
+
+ // Reset the input.
+ state = await msg("reset");
+ checkState(state, "", [], -1);
+
+ // Type an X again. The form history entry should still be gone.
+ state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ await msg("reset");
+});
+
+add_task(async function formHistory_limit() {
+ info("Check long strings are not added to form history");
+ await focusContentSearchBar();
+ const gLongString = new Array(
+ SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + 1
+ )
+ .fill("x")
+ .join("");
+ // Type and confirm a very long string.
+ let state = await msg("text", {
+ value: gLongString,
+ waitForSuggestions: true,
+ });
+ checkState(
+ state,
+ gLongString,
+ [`${gLongString}foo`, `${gLongString}bar`],
+ -1
+ );
+
+ await FormHistoryTestUtils.clear("searchbar-history");
+ let entry = await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ return Cu.waiveXrays(content).gController.addInputValueToFormHistory();
+ });
+ // There's nothing we can wait for, since addition should not be happening.
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 500));
+ Assert.equal(
+ await FormHistoryTestUtils.count("searchbar-history", {
+ source: entry.source,
+ }),
+ 0
+ );
+
+ await msg("reset");
+});
+
+add_task(async function cycleEngines() {
+ await focusContentSearchBar();
+ await msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+
+ Services.telemetry.clearEvents();
+ Services.fog.testResetFOG();
+
+ let p = SearchTestUtils.promiseSearchNotification(
+ "engine-default",
+ "browser-search-engine-modified"
+ );
+ await msg("key", { key: "VK_DOWN", modifiers: { accelKey: true } });
+ let newEngine = await p;
+ Assert.equal(
+ newEngine.name,
+ TEST_ENGINE2.name,
+ "Should have correctly cycled the engine"
+ );
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "change_default",
+ value: "user_searchbar",
+ extra: {
+ prev_id: TEST_ENGINE1.id,
+ new_id: TEST_ENGINE2.id,
+ new_name: TEST_ENGINE2.name,
+ new_load_path: TEST_ENGINE2.loadPath,
+ new_sub_url: "",
+ },
+ },
+ ],
+ { category: "search", method: "engine" }
+ );
+
+ let snapshot = await Glean.searchEngineDefault.changed.testGetValue();
+ delete snapshot[0].timestamp;
+ Assert.deepEqual(
+ snapshot[0],
+ {
+ category: "search.engine.default",
+ name: "changed",
+ extra: {
+ new_load_path: TEST_ENGINE2.loadPath,
+ previous_engine_id: TEST_ENGINE1.id,
+ change_source: "user_searchbar",
+ new_engine_id: TEST_ENGINE2.id,
+ new_display_name: TEST_ENGINE2.name,
+ new_submission_url: "",
+ },
+ },
+ "Should have received the correct event details"
+ );
+
+ p = SearchTestUtils.promiseSearchNotification(
+ "engine-default",
+ "browser-search-engine-modified"
+ );
+ await msg("key", { key: "VK_UP", modifiers: { accelKey: true } });
+ newEngine = await p;
+ Assert.equal(
+ newEngine.name,
+ TEST_ENGINE1.name,
+ "Should have correctly cycled the engine"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "change_default",
+ value: "user_searchbar",
+ extra: {
+ prev_id: TEST_ENGINE2.id,
+ new_id: TEST_ENGINE1.id,
+ new_name: TEST_ENGINE1.name,
+ new_load_path: TEST_ENGINE1.loadPath,
+ new_sub_url: "",
+ },
+ },
+ ],
+ { category: "search", method: "engine" }
+ );
+
+ snapshot = await Glean.searchEngineDefault.changed.testGetValue();
+ delete snapshot[1].timestamp;
+ Assert.deepEqual(
+ snapshot[1],
+ {
+ category: "search.engine.default",
+ name: "changed",
+ extra: {
+ new_load_path: TEST_ENGINE1.loadPath,
+ previous_engine_id: TEST_ENGINE2.id,
+ change_source: "user_searchbar",
+ new_engine_id: TEST_ENGINE1.id,
+ new_display_name: TEST_ENGINE1.name,
+ new_submission_url: "",
+ },
+ },
+ "Should have received the correct event details"
+ );
+
+ await msg("reset");
+});
+
+add_task(async function search() {
+ await focusContentSearchBar();
+
+ let modifiers = {};
+ ["altKey", "ctrlKey", "metaKey", "shiftKey"].forEach(
+ k => (modifiers[k] = true)
+ );
+
+ // Test typing a query and pressing enter.
+ let p = waitForSearch();
+ await msg("key", { key: "x", waitForSuggestions: true });
+ await msg("key", { key: "VK_RETURN", modifiers });
+ let mesg = await p;
+ let eventData = {
+ engineName: TEST_ENGINE1.name,
+ searchString: "x",
+ healthReportKey: "test",
+ searchPurpose: "test",
+ originalEvent: modifiers,
+ };
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Test typing a query, then selecting a suggestion and pressing enter.
+ p = waitForSearch();
+ await msg("key", { key: "x", waitForSuggestions: true });
+ await msg("key", "VK_DOWN");
+ await msg("key", "VK_DOWN");
+ await msg("key", { key: "VK_RETURN", modifiers });
+ mesg = await p;
+ eventData.searchString = "xfoo";
+ eventData.engineName = TEST_ENGINE1.name;
+ eventData.selection = {
+ index: 1,
+ kind: "key",
+ };
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Test typing a query, then selecting a one-off button and pressing enter.
+ p = waitForSearch();
+ await msg("key", { key: "x", waitForSuggestions: true });
+ await msg("key", "VK_UP");
+ await msg("key", "VK_UP");
+ await msg("key", { key: "VK_RETURN", modifiers });
+ mesg = await p;
+ delete eventData.selection;
+ eventData.searchString = "x";
+ eventData.engineName = TEST_ENGINE2.name;
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Test typing a query and clicking the search engine header.
+ p = waitForSearch();
+ modifiers.button = 0;
+ await msg("key", { key: "x", waitForSuggestions: true });
+ await msg("mousemove", -1);
+ await msg("click", { eltIdx: -1, modifiers });
+ mesg = await p;
+ eventData.originalEvent = modifiers;
+ eventData.engineName = TEST_ENGINE1.name;
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Test typing a query and then clicking a suggestion.
+ await msg("key", { key: "x", waitForSuggestions: true });
+ p = waitForSearch();
+ await msg("mousemove", 1);
+ await msg("click", { eltIdx: 1, modifiers });
+ mesg = await p;
+ eventData.searchString = "xfoo";
+ eventData.selection = {
+ index: 1,
+ kind: "mouse",
+ };
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Test typing a query and then clicking a one-off button.
+ await msg("key", { key: "x", waitForSuggestions: true });
+ p = waitForSearch();
+ await msg("mousemove", 3);
+ await msg("click", { eltIdx: 3, modifiers });
+ mesg = await p;
+ eventData.searchString = "x";
+ eventData.engineName = TEST_ENGINE2.name;
+ delete eventData.selection;
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Test selecting a suggestion, then clicking a one-off without deselecting the
+ // suggestion, using the keyboard.
+ delete modifiers.button;
+ await msg("key", { key: "x", waitForSuggestions: true });
+ p = waitForSearch();
+ await msg("key", "VK_DOWN");
+ await msg("key", "VK_DOWN");
+ await msg("key", "VK_TAB");
+ await msg("key", { key: "VK_RETURN", modifiers });
+ mesg = await p;
+ eventData.searchString = "xfoo";
+ eventData.selection = {
+ index: 1,
+ kind: "key",
+ };
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Test searching when using IME composition.
+ let state = await msg("startComposition", { data: "" });
+ checkState(state, "", [], -1);
+ state = await msg("changeComposition", {
+ data: "x",
+ waitForSuggestions: true,
+ });
+ checkState(
+ state,
+ "x",
+ [
+ { str: "x", type: "formHistory" },
+ { str: "xfoo", type: "formHistory" },
+ "xbar",
+ ],
+ -1
+ );
+ await msg("commitComposition");
+ delete modifiers.button;
+ p = waitForSearch();
+ await msg("key", { key: "VK_RETURN", modifiers });
+ mesg = await p;
+ eventData.searchString = "x";
+ eventData.originalEvent = modifiers;
+ eventData.engineName = TEST_ENGINE1.name;
+ delete eventData.selection;
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ state = await msg("startComposition", { data: "" });
+ checkState(state, "", [], -1);
+ state = await msg("changeComposition", {
+ data: "x",
+ waitForSuggestions: true,
+ });
+ checkState(
+ state,
+ "x",
+ [
+ { str: "x", type: "formHistory" },
+ { str: "xfoo", type: "formHistory" },
+ "xbar",
+ ],
+ -1
+ );
+
+ // Mouse over the first suggestion.
+ state = await msg("mousemove", 0);
+ checkState(
+ state,
+ "x",
+ [
+ { str: "x", type: "formHistory" },
+ { str: "xfoo", type: "formHistory" },
+ "xbar",
+ ],
+ 0
+ );
+
+ // Mouse over the second suggestion.
+ state = await msg("mousemove", 1);
+ checkState(
+ state,
+ "x",
+ [
+ { str: "x", type: "formHistory" },
+ { str: "xfoo", type: "formHistory" },
+ "xbar",
+ ],
+ 1
+ );
+
+ modifiers.button = 0;
+ p = waitForSearch();
+ await msg("click", { eltIdx: 1, modifiers });
+ mesg = await p;
+ eventData.searchString = "xfoo";
+ eventData.originalEvent = modifiers;
+ eventData.selection = {
+ index: 1,
+ kind: "mouse",
+ };
+ SimpleTest.isDeeply(eventData, mesg, "Search event data");
+
+ await promiseTab();
+ await focusContentSearchBar();
+
+ // Remove form history entries.
+ // Wait for Satchel.
+ let observePromise = new Promise(resolve => {
+ let historyCount = 2;
+ Services.obs.addObserver(function onRemove(subj, topic, data) {
+ if (data == "formhistory-remove") {
+ if (--historyCount) {
+ return;
+ }
+ Services.obs.removeObserver(onRemove, "satchel-storage-changed");
+ executeSoon(resolve);
+ }
+ }, "satchel-storage-changed");
+ });
+
+ await msg("key", { key: "x", waitForSuggestions: true });
+ await msg("key", "VK_DOWN");
+ await msg("key", "VK_DOWN");
+ await msg("key", "VK_DELETE");
+ await msg("key", "VK_DOWN");
+ await msg("key", "VK_DELETE");
+ await observePromise;
+
+ await msg("reset");
+ state = await msg("key", { key: "x", waitForSuggestions: true });
+ checkState(state, "x", ["xfoo", "xbar"], -1);
+
+ await promiseTab();
+ await focusContentSearchBar();
+ await msg("reset");
+});
+
+add_task(async function settings() {
+ await focusContentSearchBar();
+ await msg("key", { key: "VK_DOWN", waitForSuggestions: true });
+ await msg("key", "VK_UP");
+ let p = waitForSearchSettings();
+ await msg("key", "VK_RETURN");
+ await p;
+
+ await msg("reset");
+});
+
+add_task(async function cleanup() {
+ Services.search.restoreDefaultEngines();
+});
+
+function checkState(
+ actualState,
+ expectedInputVal,
+ expectedSuggestions,
+ expectedSelectedIdx,
+ expectedSelectedButtonIdx
+) {
+ expectedSuggestions = expectedSuggestions.map(sugg => {
+ return typeof sugg == "object"
+ ? sugg
+ : {
+ str: sugg,
+ type: "remote",
+ };
+ });
+
+ if (expectedSelectedIdx == -1 && expectedSelectedButtonIdx != undefined) {
+ expectedSelectedIdx =
+ expectedSuggestions.length + expectedSelectedButtonIdx;
+ }
+
+ let expectedState = {
+ selectedIndex: expectedSelectedIdx,
+ numSuggestions: expectedSuggestions.length,
+ suggestionAtIndex: expectedSuggestions.map(s => s.str),
+ isFormHistorySuggestionAtIndex: expectedSuggestions.map(
+ s => s.type == "formHistory"
+ ),
+
+ tableHidden: !expectedSuggestions.length,
+
+ inputValue: expectedInputVal,
+ ariaExpanded: !expectedSuggestions.length ? "false" : "true",
+ };
+ if (expectedSelectedButtonIdx != undefined) {
+ expectedState.selectedButtonIndex = expectedSelectedButtonIdx;
+ } else if (expectedSelectedIdx < expectedSuggestions.length) {
+ expectedState.selectedButtonIndex = -1;
+ } else {
+ expectedState.selectedButtonIndex =
+ expectedSelectedIdx - expectedSuggestions.length;
+ }
+
+ SimpleTest.isDeeply(actualState, expectedState, "State");
+}
+
+var gMsgMan;
+
+async function promiseTab() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ registerCleanupFunction(() => BrowserTestUtils.removeTab(tab));
+
+ let loadedPromise = BrowserTestUtils.firstBrowserLoaded(window);
+ openTrustedLinkIn("about:test-about-content-search-ui", "current");
+ await loadedPromise;
+}
diff --git a/browser/components/search/test/browser/browser_contentSearchUI_default.js b/browser/components/search/test/browser/browser_contentSearchUI_default.js
new file mode 100644
index 0000000000..c69326262a
--- /dev/null
+++ b/browser/components/search/test/browser/browser_contentSearchUI_default.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_ENGINE_NAME = "searchSuggestionEngine";
+const HANDOFF_PREF =
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar";
+
+let extension;
+let defaultEngine;
+let addedEngine;
+
+add_setup(async function () {
+ // Disable window occlusion. Bug 1733955
+ if (navigator.platform.indexOf("Win") == 0) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["widget.windows.window_occlusion_tracking.enabled", false]],
+ });
+ }
+
+ defaultEngine = await Services.search.getDefault();
+
+ extension = await SearchTestUtils.installSearchExtension({
+ id: TEST_ENGINE_NAME,
+ name: TEST_ENGINE_NAME,
+ suggest_url:
+ "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs",
+ suggest_url_get_params: "query={searchTerms}",
+ });
+
+ addedEngine = await Services.search.getEngineByName(TEST_ENGINE_NAME);
+
+ // Enable suggestions in this test. Otherwise, the string in the content
+ // search box changes.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.suggest.searches", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ await Services.search.setDefault(
+ defaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ });
+});
+
+async function ensureIcon(tab, expectedIcon) {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [expectedIcon],
+ async function (icon) {
+ await ContentTaskUtils.waitForCondition(() => !content.document.hidden);
+
+ let computedStyle = content.window.getComputedStyle(
+ content.document.body
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => computedStyle.getPropertyValue("--newtab-search-icon") != "null",
+ "Search Icon not set."
+ );
+
+ Assert.equal(
+ computedStyle.getPropertyValue("--newtab-search-icon"),
+ `url(${icon})`,
+ "Should have the expected icon"
+ );
+ }
+ );
+}
+
+async function ensurePlaceholder(tab, expectedId, expectedEngine) {
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [expectedId, expectedEngine],
+ async function (id, engine) {
+ await ContentTaskUtils.waitForCondition(() => !content.document.hidden);
+
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(".search-handoff-button"),
+ "l10n ID not set."
+ );
+ let buttonNode = content.document.querySelector(".search-handoff-button");
+ let expectedAttributes = { id, args: engine ? { engine } : null };
+ Assert.deepEqual(
+ content.document.l10n.getAttributes(buttonNode),
+ expectedAttributes,
+ "Expected updated l10n ID and args."
+ );
+ }
+ );
+}
+
+async function runNewTabTest(isHandoff) {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ url: "about:newtab",
+ gBrowser,
+ waitForLoad: false,
+ });
+
+ let engineIcon = defaultEngine.getIconURLBySize(16, 16);
+
+ await ensureIcon(tab, engineIcon);
+ if (isHandoff) {
+ await ensurePlaceholder(
+ tab,
+ "newtab-search-box-handoff-input",
+ Services.search.defaultEngine.name
+ );
+ }
+
+ await Services.search.setDefault(
+ addedEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ // We only show the engine's own icon for app provided engines, otherwise show
+ // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19
+ await ensureIcon(tab, "chrome://global/skin/icons/search-glass.svg");
+ if (isHandoff) {
+ await ensurePlaceholder(tab, "newtab-search-box-handoff-input-no-engine");
+ }
+
+ // Disable suggestions in the Urlbar. This should update the placeholder
+ // string since handoff will now enter search mode.
+ if (isHandoff) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.suggest.searches", false]],
+ });
+ await ensurePlaceholder(tab, "newtab-search-box-input");
+ await SpecialPowers.popPrefEnv();
+ }
+
+ await Services.search.setDefault(
+ defaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function test_content_search_attributes() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[HANDOFF_PREF, true]],
+ });
+
+ await runNewTabTest(true);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_content_search_attributes_no_handoff() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[HANDOFF_PREF, false]],
+ });
+
+ await runNewTabTest(false);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_content_search_attributes_in_private_window() {
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ waitForTabURL: "about:privatebrowsing",
+ });
+ let tab = win.gBrowser.selectedTab;
+
+ let engineIcon = defaultEngine.getIconURLBySize(16, 16);
+
+ await ensureIcon(tab, engineIcon);
+ await ensurePlaceholder(
+ tab,
+ "about-private-browsing-handoff",
+ Services.search.defaultEngine.name
+ );
+
+ await Services.search.setDefault(
+ addedEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ // We only show the engine's own icon for app provided engines, otherwise show
+ // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19
+ await ensureIcon(tab, "chrome://global/skin/icons/search-glass.svg");
+ await ensurePlaceholder(tab, "about-private-browsing-handoff-no-engine");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.suggest.searches", false]],
+ });
+ await ensurePlaceholder(tab, "about-private-browsing-search-btn");
+ await SpecialPowers.popPrefEnv();
+
+ await Services.search.setDefault(
+ defaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_content_search_permanent_private_browsing() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [HANDOFF_PREF, true],
+ ["browser.privatebrowsing.autostart", true],
+ ],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await runNewTabTest(true);
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/search/test/browser/browser_contextSearchTabPosition.js b/browser/components/search/test/browser/browser_contextSearchTabPosition.js
new file mode 100644
index 0000000000..345167c5b8
--- /dev/null
+++ b/browser/components/search/test/browser/browser_contextSearchTabPosition.js
@@ -0,0 +1,94 @@
+/* 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/. */
+
+let engine;
+
+add_setup(async function () {
+ engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine.xml",
+ setAsDefault: true,
+ });
+});
+
+add_task(async function test() {
+ let histogramKey = "other-" + engine.name + ".contextmenu";
+ let numSearchesBefore = 0;
+
+ try {
+ let hs = Services.telemetry
+ .getKeyedHistogramById("SEARCH_COUNTS")
+ .snapshot();
+ if (histogramKey in hs) {
+ numSearchesBefore = hs[histogramKey].sum;
+ }
+ } catch (ex) {
+ // No searches performed yet, not a problem, |numSearchesBefore| is 0.
+ }
+
+ let tabs = [];
+ let tabsLoadedDeferred = new Deferred();
+
+ function tabAdded(event) {
+ let tab = event.target;
+ tabs.push(tab);
+
+ // We wait for the blank tab and the two context searches tabs to open.
+ if (tabs.length == 3) {
+ tabsLoadedDeferred.resolve();
+ }
+ }
+
+ let container = gBrowser.tabContainer;
+ container.addEventListener("TabOpen", tabAdded);
+
+ BrowserTestUtils.addTab(gBrowser, "about:blank");
+ BrowserSearch.loadSearchFromContext(
+ "mozilla",
+ false,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ Services.scriptSecurityManager.getSystemPrincipal().csp,
+ new MouseEvent("click")
+ );
+ BrowserSearch.loadSearchFromContext(
+ "firefox",
+ false,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ Services.scriptSecurityManager.getSystemPrincipal().csp,
+ new MouseEvent("click")
+ );
+
+ // Wait for all the tabs to open.
+ await tabsLoadedDeferred.promise;
+
+ is(tabs[0], gBrowser.tabs[3], "blank tab has been pushed to the end");
+ is(
+ tabs[1],
+ gBrowser.tabs[1],
+ "first search tab opens next to the current tab"
+ );
+ is(
+ tabs[2],
+ gBrowser.tabs[2],
+ "second search tab opens next to the first search tab"
+ );
+
+ container.removeEventListener("TabOpen", tabAdded);
+ tabs.forEach(gBrowser.removeTab, gBrowser);
+
+ // Make sure that the context searches are correctly recorded in telemetry.
+ // Telemetry is not updated synchronously here, we must wait for it.
+ await TestUtils.waitForCondition(() => {
+ let hs = Services.telemetry
+ .getKeyedHistogramById("SEARCH_COUNTS")
+ .snapshot();
+ return histogramKey in hs && hs[histogramKey].sum == numSearchesBefore + 2;
+ }, "The histogram must contain the correct search count");
+});
+
+function Deferred() {
+ this.promise = new Promise((resolve, reject) => {
+ this.resolve = resolve;
+ this.reject = reject;
+ });
+}
diff --git a/browser/components/search/test/browser/browser_contextmenu.js b/browser/components/search/test/browser/browser_contextmenu.js
new file mode 100644
index 0000000000..67ba48da72
--- /dev/null
+++ b/browser/components/search/test/browser/browser_contextmenu.js
@@ -0,0 +1,249 @@
+/* Any copyright is dedicated to the Public Domain.
+ * * http://creativecommons.org/publicdomain/zero/1.0/ */
+/*
+ * Test searching for the selected text using the context menu
+ */
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+const ENGINE_NAME = "mozSearch";
+const PRIVATE_ENGINE_NAME = "mozPrivateSearch";
+const ENGINE_DATA = new Map([
+ [
+ ENGINE_NAME,
+ "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs",
+ ],
+ [PRIVATE_ENGINE_NAME, "https://example.com:443/browser/"],
+]);
+
+let engine;
+let privateEngine;
+let extensions = [];
+let oldDefaultEngine;
+let oldDefaultPrivateEngine;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.separatePrivateDefault", true],
+ ["browser.search.separatePrivateDefault.ui.enabled", true],
+ ],
+ });
+
+ await Services.search.init();
+
+ for (let [name, search_url] of ENGINE_DATA) {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ chrome_settings_overrides: {
+ search_provider: {
+ name,
+ search_url,
+ params: [
+ {
+ name: "test",
+ value: "{searchTerms}",
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ await extension.startup();
+ await AddonTestUtils.waitForSearchProviderStartup(extension);
+ extensions.push(extension);
+ }
+
+ engine = await Services.search.getEngineByName(ENGINE_NAME);
+ Assert.ok(engine, "Got a search engine");
+ oldDefaultEngine = await Services.search.getDefault();
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ privateEngine = await Services.search.getEngineByName(PRIVATE_ENGINE_NAME);
+ Assert.ok(privateEngine, "Got a search engine");
+ oldDefaultPrivateEngine = await Services.search.getDefaultPrivate();
+ await Services.search.setDefaultPrivate(
+ privateEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+});
+
+async function checkContextMenu(
+ win,
+ expectedName,
+ expectedBaseUrl,
+ expectedPrivateName
+) {
+ let contextMenu = win.document.getElementById("contentAreaContextMenu");
+ Assert.ok(contextMenu, "Got context menu XUL");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "https://example.com/browser/browser/components/search/test/browser/test_search.html"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () {
+ return new Promise(resolve => {
+ content.document.addEventListener(
+ "selectionchange",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ content.document.getSelection().selectAllChildren(content.document.body);
+ });
+ });
+
+ let eventDetails = { type: "contextmenu", button: 2 };
+
+ let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ eventDetails,
+ win.gBrowser.selectedBrowser
+ );
+ await popupPromise;
+
+ info("checkContextMenu");
+ let searchItem = contextMenu.getElementsByAttribute(
+ "id",
+ "context-searchselect"
+ )[0];
+ Assert.ok(searchItem, "Got search context menu item");
+ Assert.equal(
+ searchItem.label,
+ "Search " + expectedName + " for \u201ctest%20search\u201d",
+ "Check context menu label"
+ );
+ Assert.equal(
+ searchItem.disabled,
+ false,
+ "Check that search context menu item is enabled"
+ );
+
+ let loaded = BrowserTestUtils.waitForNewTab(
+ win.gBrowser,
+ expectedBaseUrl + "?test=test%2520search",
+ true
+ );
+ contextMenu.activateItem(searchItem);
+ let searchTab = await loaded;
+ let browser = win.gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async function () {
+ Assert.ok(
+ !/error/.test(content.document.body.innerHTML),
+ "Ensure there were no errors loading the search page"
+ );
+ });
+
+ searchItem = contextMenu.getElementsByAttribute(
+ "id",
+ "context-searchselect-private"
+ )[0];
+ Assert.ok(searchItem, "Got search in private window context menu item");
+ if (PrivateBrowsingUtils.isWindowPrivate(win)) {
+ Assert.ok(searchItem.hidden, "Search in private window should be hidden");
+ } else {
+ let expectedLabel = expectedPrivateName
+ ? "Search with " + expectedPrivateName + " in a Private Window"
+ : "Search in a Private Window";
+ Assert.equal(searchItem.label, expectedLabel, "Check context menu label");
+ Assert.equal(
+ searchItem.disabled,
+ false,
+ "Check that search context menu item is enabled"
+ );
+ }
+
+ contextMenu.hidePopup();
+
+ BrowserTestUtils.removeTab(searchTab);
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function test_normalWindow() {
+ await checkContextMenu(
+ window,
+ ENGINE_NAME,
+ "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs",
+ PRIVATE_ENGINE_NAME
+ );
+});
+
+add_task(async function test_privateWindow() {
+ const win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.closeWindow(win);
+ });
+
+ await checkContextMenu(
+ win,
+ PRIVATE_ENGINE_NAME,
+ "https://example.com/browser/"
+ );
+});
+
+add_task(async function test_normalWindow_sameDefaults() {
+ // Set the private default engine to be the same as the current default engine
+ // in 'normal' mode.
+ await Services.search.setDefaultPrivate(
+ await Services.search.getDefault(),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ await checkContextMenu(
+ window,
+ ENGINE_NAME,
+ "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs"
+ );
+});
+
+add_task(async function test_privateWindow_no_separate_engine() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We want select events to be fired.
+ ["browser.search.separatePrivateDefault", false],
+ ],
+ });
+
+ const win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.closeWindow(win);
+ });
+
+ await checkContextMenu(
+ win,
+ ENGINE_NAME,
+ "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs"
+ );
+});
+
+// We can't do the unload within registerCleanupFunction as that's too late for
+// the test to be happy. Do it into a cleanup "test" here instead.
+add_task(async function cleanup() {
+ await Services.search.setDefault(
+ oldDefaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ await Services.search.setDefaultPrivate(
+ oldDefaultPrivateEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ await Services.search.removeEngine(engine);
+ await Services.search.removeEngine(privateEngine);
+
+ for (let extension of extensions) {
+ await extension.unload();
+ }
+});
diff --git a/browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js b/browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js
new file mode 100644
index 0000000000..ed3fd6901d
--- /dev/null
+++ b/browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js
@@ -0,0 +1,183 @@
+/* Any copyright is dedicated to the Public Domain.
+ * * http://creativecommons.org/publicdomain/zero/1.0/ */
+/*
+ * Test searching for the selected text using the context menu
+ */
+
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+
+SearchTestUtils.init(this);
+
+const ENGINE_NAME = "mozSearch";
+const ENGINE_URL =
+ "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs";
+
+add_setup(async function () {
+ await Services.search.init();
+
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: ENGINE_NAME,
+ search_url: ENGINE_URL,
+ search_url_get_params: "test={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+});
+
+async function openNewSearchTab(event_args, expect_new_window = false) {
+ // open context menu with right click
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+
+ let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupPromise;
+
+ let searchItem = contextMenu.getElementsByAttribute(
+ "id",
+ "context-searchselect"
+ )[0];
+
+ // open new search tab with desired modifiers
+ let searchTabPromise;
+ if (expect_new_window) {
+ searchTabPromise = BrowserTestUtils.waitForNewWindow({
+ url: ENGINE_URL + "?test=test%2520search",
+ });
+ } else {
+ searchTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ ENGINE_URL + "?test=test%2520search",
+ true
+ );
+ }
+
+ if ("button" in event_args) {
+ // Bug 1704879: activateItem does not currently support button
+ EventUtils.synthesizeMouseAtCenter(searchItem, event_args);
+ } else {
+ contextMenu.activateItem(searchItem, event_args);
+ }
+
+ if (expect_new_window) {
+ let win = await searchTabPromise;
+ return win.gBrowser.selectedTab;
+ }
+ return searchTabPromise;
+}
+
+add_task(async function test_whereToOpenLink() {
+ // open search test page and select search text
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/browser/browser/components/search/test/browser/test_search.html"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () {
+ return new Promise(resolve => {
+ content.document.addEventListener(
+ "selectionchange",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+ content.document.getSelection().selectAllChildren(content.document.body);
+ });
+ });
+
+ // check where context search opens for different buttons/modifiers
+ let searchTab = await openNewSearchTab({});
+ is(
+ searchTab,
+ gBrowser.selectedTab,
+ "Search tab is opened in foreground (no modifiers)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+
+ // TODO bug 1704883: Re-enable this subtest. Native context menus on macOS do
+ // not yet support alternate mouse buttons.
+ if (
+ !AppConstants.platform == "macosx" ||
+ !Services.prefs.getBoolPref("widget.macos.native-context-menus", false)
+ ) {
+ searchTab = await openNewSearchTab({ button: 1 });
+ isnot(
+ searchTab,
+ gBrowser.selectedTab,
+ "Search tab is opened in background (middle mouse)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+ }
+
+ searchTab = await openNewSearchTab({ ctrlKey: true });
+ isnot(
+ searchTab,
+ gBrowser.selectedTab,
+ "Search tab is opened in background (Ctrl)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+
+ let current_browser = gBrowser.selectedBrowser;
+ searchTab = await openNewSearchTab({ shiftKey: true }, true);
+ isnot(
+ current_browser,
+ gBrowser.getBrowserForTab(searchTab),
+ "Search tab is opened in new window (Shift)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+
+ info("flipping browser.search.context.loadInBackground and re-checking");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.context.loadInBackground", true]],
+ });
+
+ searchTab = await openNewSearchTab({});
+ isnot(
+ searchTab,
+ gBrowser.selectedTab,
+ "Search tab is opened in background (no modifiers)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+
+ // TODO bug 1704883: Re-enable this subtest. Native context menus on macOS do
+ // not yet support alternate mouse buttons.
+ if (
+ !AppConstants.platform == "macosx" ||
+ !Services.prefs.getBoolPref("widget.macos.native-context-menus", false)
+ ) {
+ searchTab = await openNewSearchTab({ button: 1 });
+ is(
+ searchTab,
+ gBrowser.selectedTab,
+ "Search tab is opened in foreground (middle mouse)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+ }
+
+ searchTab = await openNewSearchTab({ ctrlKey: true });
+ is(
+ searchTab,
+ gBrowser.selectedTab,
+ "Search tab is opened in foreground (Ctrl)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+
+ current_browser = gBrowser.selectedBrowser;
+ searchTab = await openNewSearchTab({ shiftKey: true }, true);
+ isnot(
+ current_browser,
+ gBrowser.getBrowserForTab(searchTab),
+ "Search tab is opened in new window (Shift)"
+ );
+ BrowserTestUtils.removeTab(searchTab);
+
+ // cleanup
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_defaultPrivate_nimbus.js b/browser/components/search/test/browser/browser_defaultPrivate_nimbus.js
new file mode 100644
index 0000000000..ce5acc91a0
--- /dev/null
+++ b/browser/components/search/test/browser/browser_defaultPrivate_nimbus.js
@@ -0,0 +1,155 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+});
+
+const CONFIG_DEFAULT = [
+ {
+ webExtension: { id: "basic@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ default: "yes",
+ },
+ {
+ webExtension: { id: "private@search.mozilla.org" },
+ appliesTo: [
+ {
+ experiment: "testing",
+ included: { everywhere: true },
+ },
+ ],
+ defaultPrivate: "yes",
+ },
+];
+
+SearchTestUtils.init(this);
+
+add_setup(async () => {
+ // Use engines in test directory
+ let searchExtensions = getChromeDir(getResolvedURI(gTestPath));
+ searchExtensions.append("search-engines");
+ await SearchTestUtils.useMochitestEngines(searchExtensions);
+
+ // Current default values.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.separatePrivateDefault.ui.enabled", false],
+ ["browser.search.separatePrivateDefault.urlbarResult.enabled", false],
+ ["browser.search.separatePrivateDefault", true],
+ ["browser.urlbar.suggest.searches", true],
+ ],
+ });
+
+ SearchTestUtils.useMockIdleService();
+ await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT);
+
+ registerCleanupFunction(async () => {
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await SearchTestUtils.updateRemoteSettingsConfig();
+ await settingsWritten;
+ });
+});
+
+add_task(async function test_nimbus_experiment() {
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "basic",
+ "Should have basic as private default while not in experiment"
+ );
+ await ExperimentAPI.ready();
+
+ let reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "searchConfiguration",
+ value: {
+ seperatePrivateDefaultUIEnabled: true,
+ seperatePrivateDefaultUrlbarResultEnabled: false,
+ experiment: "testing",
+ },
+ });
+ await reloadObserved;
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "private",
+ "Should have private as private default while in experiment"
+ );
+ reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+ await doExperimentCleanup();
+ await reloadObserved;
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "basic",
+ "Should turn off private default and restore default engine after experiment"
+ );
+});
+
+add_task(async function test_nimbus_experiment_urlbar_result_enabled() {
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "basic",
+ "Should have basic as private default while not in experiment"
+ );
+ await ExperimentAPI.ready();
+
+ let reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "searchConfiguration",
+ value: {
+ seperatePrivateDefaultUIEnabled: true,
+ seperatePrivateDefaultUrlbarResultEnabled: true,
+ experiment: "testing",
+ },
+ });
+ await reloadObserved;
+ Assert.equal(
+ Services.search.separatePrivateDefaultUrlbarResultEnabled,
+ true,
+ "Should have set the urlbar result enabled value to true"
+ );
+ reloadObserved =
+ SearchTestUtils.promiseSearchNotification("engines-reloaded");
+ await doExperimentCleanup();
+ await reloadObserved;
+ Assert.equal(
+ Services.search.defaultPrivateEngine.name,
+ "basic",
+ "Should turn off private default and restore default engine after experiment"
+ );
+});
+
+add_task(async function test_non_experiment_prefs() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.separatePrivateDefault.ui.enabled", false]],
+ });
+ let uiPref = () =>
+ Services.prefs.getBoolPref(
+ "browser.search.separatePrivateDefault.ui.enabled"
+ );
+ Assert.equal(uiPref(), false, "defaulted false");
+ await ExperimentAPI.ready();
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "privatesearch",
+ value: {
+ seperatePrivateDefaultUIEnabled: true,
+ },
+ });
+ Assert.equal(uiPref(), false, "Pref did not change without experiment");
+ await doExperimentCleanup();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/search/test/browser/browser_google_behavior.js b/browser/components/search/test/browser/browser_google_behavior.js
new file mode 100644
index 0000000000..1d58ac77ee
--- /dev/null
+++ b/browser/components/search/test/browser/browser_google_behavior.js
@@ -0,0 +1,215 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test Google search plugin URLs
+ * TODO: This test is a near duplicate of browser_searchEngine_behaviors.js but
+ * specific to Google. This is required due to bug 1315953.
+ *
+ * Note: Although we have tests for codes in
+ * toolkit/components/tests/xpcshell/searchconfigs, we also need this test as an
+ * integration test to check the search service to selector integration is
+ * working correctly (especially the ESR codes).
+ */
+
+"use strict";
+
+let searchEngineDetails = [
+ {
+ alias: "g",
+ codes: {
+ context: "",
+ keyword: "",
+ newTab: "",
+ submission: "",
+ },
+ name: "Google",
+ },
+];
+
+let region = Services.prefs.getCharPref("browser.search.region");
+let code = "";
+switch (region) {
+ case "US":
+ if (SearchUtils.MODIFIED_APP_CHANNEL == "esr") {
+ code = "firefox-b-1-e";
+ } else {
+ code = "firefox-b-1-d";
+ }
+ break;
+ case "DE":
+ if (SearchUtils.MODIFIED_APP_CHANNEL == "esr") {
+ code = "firefox-b-e";
+ } else {
+ code = "firefox-b-d";
+ }
+ break;
+}
+
+if (code) {
+ let codes = searchEngineDetails[0].codes;
+ codes.context = code;
+ codes.newTab = code;
+ codes.submission = code;
+ codes.keyword = code;
+}
+
+function promiseContentSearchReady(browser) {
+ return SpecialPowers.spawn(browser, [], async function (args) {
+ return new Promise(resolve => {
+ SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+ if (content.wrappedJSObject.gContentSearchController) {
+ let searchController = content.wrappedJSObject.gContentSearchController;
+ if (searchController.defaultEngine) {
+ resolve();
+ }
+ }
+
+ content.addEventListener(
+ "ContentSearchService",
+ function listener(aEvent) {
+ if (aEvent.detail.type == "State") {
+ content.removeEventListener("ContentSearchService", listener);
+ resolve();
+ }
+ }
+ );
+ });
+ });
+}
+
+add_setup(async function () {
+ await Services.search.init();
+});
+
+for (let engine of searchEngineDetails) {
+ add_task(async function () {
+ let previouslySelectedEngine = Services.search.defaultEngine;
+
+ registerCleanupFunction(function () {
+ Services.search.defaultEngine = previouslySelectedEngine;
+ });
+
+ await testSearchEngine(engine);
+ });
+}
+
+async function testSearchEngine(engineDetails) {
+ let engine = Services.search.getEngineByName(engineDetails.name);
+ Assert.ok(engine, `${engineDetails.name} is installed`);
+
+ Services.search.defaultEngine = engine;
+ engine.alias = engineDetails.alias;
+
+ // Test search URLs (including purposes).
+ let url = engine.getSubmission("foo").uri.spec;
+ let urlParams = new URLSearchParams(url.split("?")[1]);
+ Assert.equal(urlParams.get("q"), "foo", "Check search URL for 'foo'");
+
+ let engineTests = [
+ {
+ name: "context menu search",
+ code: engineDetails.codes.context,
+ run() {
+ // Simulate a contextmenu search
+ // FIXME: This is a bit "low-level"...
+ BrowserSearch._loadSearch(
+ "foo",
+ false,
+ false,
+ "contextmenu",
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ },
+ },
+ {
+ name: "keyword search",
+ code: engineDetails.codes.keyword,
+ run() {
+ gURLBar.value = "? foo";
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ },
+ {
+ name: "keyword search with alias",
+ code: engineDetails.codes.keyword,
+ run() {
+ gURLBar.value = `${engineDetails.alias} foo`;
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ },
+ {
+ name: "search bar search",
+ code: engineDetails.codes.submission,
+ async preTest() {
+ await gCUITestUtils.addSearchBar();
+ },
+ run() {
+ let sb = BrowserSearch.searchBar;
+ sb.focus();
+ sb.value = "foo";
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ postTest() {
+ BrowserSearch.searchBar.value = "";
+ gCUITestUtils.removeSearchBar();
+ },
+ },
+ {
+ name: "new tab search",
+ code: engineDetails.codes.newTab,
+ async preTest(tab) {
+ let browser = tab.linkedBrowser;
+ BrowserTestUtils.loadURIString(browser, "about:newtab");
+ await BrowserTestUtils.browserLoaded(browser, false, "about:newtab");
+
+ await promiseContentSearchReady(browser);
+ },
+ async run(tab) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function (args) {
+ let input = content.document.querySelector("input[id*=search-]");
+ input.focus();
+ input.value = "foo";
+ });
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ },
+ ];
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ for (let test of engineTests) {
+ info(`Running: ${test.name}`);
+
+ if (test.preTest) {
+ await test.preTest(tab);
+ }
+
+ let googleUrl =
+ "https://www.google.com/search?client=" + test.code + "&q=foo";
+ let promises = [
+ BrowserTestUtils.waitForDocLoadAndStopIt(googleUrl, tab),
+ BrowserTestUtils.browserStopped(tab.linkedBrowser, googleUrl, true),
+ ];
+
+ await test.run(tab);
+
+ await Promise.all(promises);
+
+ if (test.postTest) {
+ await test.postTest(tab);
+ }
+ }
+
+ engine.alias = undefined;
+ BrowserTestUtils.removeTab(tab);
+}
diff --git a/browser/components/search/test/browser/browser_hiddenOneOffs_cleanup.js b/browser/components/search/test/browser/browser_hiddenOneOffs_cleanup.js
new file mode 100644
index 0000000000..8be90b3288
--- /dev/null
+++ b/browser/components/search/test/browser/browser_hiddenOneOffs_cleanup.js
@@ -0,0 +1,117 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+const testPref = "Foo,FooDupe";
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.search.hiddenOneOffs");
+});
+
+add_task(async function test_remove() {
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine_dupe.xml",
+ });
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine.xml",
+ });
+ Services.prefs.setCharPref("browser.search.hiddenOneOffs", testPref);
+
+ info("Removing testEngine_dupe.xml");
+ await Services.search.removeEngine(
+ Services.search.getEngineByName("FooDupe")
+ );
+
+ let hiddenOneOffs = Services.prefs
+ .getCharPref("browser.search.hiddenOneOffs")
+ .split(",");
+
+ is(
+ hiddenOneOffs.length,
+ 1,
+ "hiddenOneOffs has the correct engine count post removal."
+ );
+ is(
+ hiddenOneOffs.some(x => x == "FooDupe"),
+ false,
+ "Removed Engine is not in hiddenOneOffs after removal"
+ );
+ is(
+ hiddenOneOffs.some(x => x == "Foo"),
+ true,
+ "Current hidden engine is not affected by removal."
+ );
+
+ info("Removing testEngine.xml");
+ await Services.search.removeEngine(Services.search.getEngineByName("Foo"));
+
+ is(
+ Services.prefs.getCharPref("browser.search.hiddenOneOffs"),
+ "",
+ "hiddenOneOffs is empty after removing all hidden engines."
+ );
+});
+
+add_task(async function test_add() {
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine.xml",
+ });
+ info("setting prefs to " + testPref);
+ Services.prefs.setCharPref("browser.search.hiddenOneOffs", testPref);
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine_dupe.xml",
+ });
+ let hiddenOneOffs = Services.prefs
+ .getCharPref("browser.search.hiddenOneOffs")
+ .split(",");
+
+ is(
+ hiddenOneOffs.length,
+ 1,
+ "hiddenOneOffs has the correct number of hidden engines present post add."
+ );
+ is(
+ hiddenOneOffs.some(x => x == "FooDupe"),
+ false,
+ "Added engine is not present in hidden list."
+ );
+ is(
+ hiddenOneOffs.some(x => x == "Foo"),
+ true,
+ "Adding an engine does not remove engines from hidden list."
+ );
+});
+
+add_task(async function test_diacritics() {
+ const diacritic_engine = "Foo \u2661";
+ let { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+ );
+
+ Preferences.set("browser.search.hiddenOneOffs", diacritic_engine);
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine_diacritics.xml",
+ });
+
+ let hiddenOneOffs = Preferences.get("browser.search.hiddenOneOffs").split(
+ ","
+ );
+ is(
+ hiddenOneOffs.some(x => x == diacritic_engine),
+ false,
+ "Observer cleans up added hidden engines that include a diacritic."
+ );
+
+ Preferences.set("browser.search.hiddenOneOffs", diacritic_engine);
+
+ info("Removing testEngine_diacritics.xml");
+ await Services.search.removeEngine(
+ Services.search.getEngineByName(diacritic_engine)
+ );
+
+ hiddenOneOffs = Preferences.get("browser.search.hiddenOneOffs").split(",");
+ is(
+ hiddenOneOffs.some(x => x == diacritic_engine),
+ false,
+ "Observer cleans up removed hidden engines that include a diacritic."
+ );
+});
diff --git a/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js b/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js
new file mode 100644
index 0000000000..a583aad7b3
--- /dev/null
+++ b/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js
@@ -0,0 +1,74 @@
+/* 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 that keyboard navigation in the search panel works as designed.
+
+const searchPopup = document.getElementById("PopupSearchAutoComplete");
+
+const diacritic_engine = "Foo \u2661";
+
+var { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+let searchIcon;
+
+add_setup(async function () {
+ let searchbar = await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+ searchIcon = searchbar.querySelector(".searchbar-search-button");
+
+ let defaultEngine = await Services.search.getDefault();
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine_diacritics.xml",
+ });
+ registerCleanupFunction(async () => {
+ await Services.search.setDefault(
+ defaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ Services.prefs.clearUserPref("browser.search.hiddenOneOffs");
+ });
+});
+
+add_task(async function test_hidden() {
+ Preferences.set("browser.search.hiddenOneOffs", diacritic_engine);
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ await promise;
+
+ ok(
+ !getOneOffs().some(x => x.getAttribute("tooltiptext") == diacritic_engine),
+ "Search engines with diacritics are hidden when added to hiddenOneOffs preference."
+ );
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ info("Closing search panel");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await promise;
+});
+
+add_task(async function test_shown() {
+ Preferences.set("browser.search.hiddenOneOffs", "");
+
+ let oneOffsContainer = searchPopup.searchOneOffsContainer;
+ let shownPromise = promiseEvent(searchPopup, "popupshown");
+ let builtPromise = promiseEvent(oneOffsContainer, "rebuild");
+ info("Opening search panel");
+
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ await Promise.all([shownPromise, builtPromise]);
+
+ ok(
+ getOneOffs().some(x => x.getAttribute("tooltiptext") == diacritic_engine),
+ "Search engines with diacritics are shown when removed from hiddenOneOffs preference."
+ );
+
+ let promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+});
diff --git a/browser/components/search/test/browser/browser_ime_composition.js b/browser/components/search/test/browser/browser_ime_composition.js
new file mode 100644
index 0000000000..763885aad6
--- /dev/null
+++ b/browser/components/search/test/browser/browser_ime_composition.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests ime composition handling on searchbar.
+
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ registerCleanupFunction(async function () {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function test_composition_with_focus() {
+ info("Open a page");
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com");
+
+ info("Focus on the search bar");
+ const searchBarTextBox = BrowserSearch.searchBar.textbox;
+ EventUtils.synthesizeMouseAtCenter(searchBarTextBox, {});
+ is(
+ document.activeElement,
+ BrowserSearch.searchBar.textbox,
+ "The text box of search bar has focus"
+ );
+
+ info("Do search with new tab");
+ EventUtils.synthesizeKey("x");
+ EventUtils.synthesizeKey("KEY_Enter", { altKey: true, type: "keydown" });
+ is(gBrowser.tabs.length, 3, "Alt+Return key added new tab");
+ await TestUtils.waitForCondition(
+ () => document.activeElement === gBrowser.selectedBrowser,
+ "Wait for focus to be moved to the browser"
+ );
+ info("The focus is moved to the browser");
+
+ info("Focus on the search bar again");
+ EventUtils.synthesizeMouseAtCenter(searchBarTextBox, {});
+ is(
+ document.activeElement,
+ BrowserSearch.searchBar.textbox,
+ "The textbox of search bar has focus again"
+ );
+
+ info("Type some characters during composition");
+ const string = "ex";
+ EventUtils.synthesizeCompositionChange({
+ composition: {
+ string,
+ clauses: [
+ {
+ length: string.length,
+ attr: Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE,
+ },
+ ],
+ },
+ caret: { start: string.length, length: 0 },
+ key: { key: string[string.length - 1] },
+ });
+
+ info("Commit the composition");
+ EventUtils.synthesizeComposition({
+ type: "compositioncommitasis",
+ key: { key: "KEY_Enter" },
+ });
+ is(
+ document.activeElement,
+ BrowserSearch.searchBar.textbox,
+ "The search bar still has focus"
+ );
+
+ // Close all open tabs
+ await BrowserTestUtils.removeTab(gBrowser.tabs[2]);
+ await BrowserTestUtils.removeTab(gBrowser.tabs[1]);
+});
diff --git a/browser/components/search/test/browser/browser_oneOffContextMenu.js b/browser/components/search/test/browser/browser_oneOffContextMenu.js
new file mode 100644
index 0000000000..c036a5f007
--- /dev/null
+++ b/browser/components/search/test/browser/browser_oneOffContextMenu.js
@@ -0,0 +1,89 @@
+"use strict";
+
+const TEST_ENGINE_NAME = "Foo";
+const TEST_ENGINE_BASENAME = "testEngine.xml";
+
+let searchbar;
+let searchIcon;
+
+add_setup(async function () {
+ searchbar = await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+ searchIcon = searchbar.querySelector(".searchbar-search-button");
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME,
+ });
+});
+
+add_task(async function telemetry() {
+ let searchPopup = document.getElementById("PopupSearchAutoComplete");
+ let oneOffInstance = searchPopup.oneOffButtons;
+
+ let oneOffButtons = oneOffInstance.buttons;
+
+ // Open the popup.
+ let shownPromise = promiseEvent(searchPopup, "popupshown");
+ let builtPromise = promiseEvent(oneOffInstance, "rebuild");
+ info("Opening search panel");
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ await Promise.all([shownPromise, builtPromise]);
+
+ // Get the one-off button for the test engine.
+ let oneOffButton;
+ for (let node of oneOffButtons.children) {
+ if (node.engine && node.engine.name == TEST_ENGINE_NAME) {
+ oneOffButton = node;
+ break;
+ }
+ }
+ Assert.notEqual(
+ oneOffButton,
+ undefined,
+ "One-off for test engine should exist"
+ );
+
+ // Open the context menu on the one-off.
+ let contextMenu = oneOffInstance.querySelector(
+ ".search-one-offs-context-menu"
+ );
+ let promise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(oneOffButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await promise;
+
+ // Click the Search in New Tab menu item.
+ let searchInNewTabMenuItem = contextMenu.querySelector(
+ ".search-one-offs-context-open-in-new-tab"
+ );
+ promise = BrowserTestUtils.waitForNewTab(gBrowser);
+ contextMenu.activateItem(searchInNewTabMenuItem);
+ let tab = await promise;
+
+ // By default the search will open in the background and the popup will stay open:
+ promise = promiseEvent(searchPopup, "popuphidden");
+ info("Closing search panel");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await promise;
+
+ // Check the loaded tab.
+ Assert.equal(
+ tab.linkedBrowser.currentURI.spec,
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/",
+ "Expected search tab should have loaded"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Move the cursor out of the panel area to avoid messing with other tests.
+ await EventUtils.promiseNativeMouseEvent({
+ type: "mousemove",
+ target: searchbar,
+ offsetX: 0,
+ offsetY: 0,
+ });
+});
diff --git a/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js b/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js
new file mode 100644
index 0000000000..569e56b9ff
--- /dev/null
+++ b/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js
@@ -0,0 +1,236 @@
+"use strict";
+
+const TEST_ENGINE_NAME = "Foo";
+const TEST_ENGINE_BASENAME = "testEngine.xml";
+const SEARCHBAR_BASE_ID = "searchbar-engine-one-off-item-";
+
+let originalEngine;
+let originalPrivateEngine;
+
+async function resetEngines() {
+ await Services.search.setDefault(
+ originalEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ await Services.search.setDefaultPrivate(
+ originalPrivateEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+}
+
+registerCleanupFunction(resetEngines);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.separatePrivateDefault.ui.enabled", true],
+ ["browser.search.separatePrivateDefault", true],
+ ["browser.search.widget.inNavBar", true],
+ ],
+ });
+ originalEngine = await Services.search.getDefault();
+ originalPrivateEngine = await Services.search.getDefaultPrivate();
+ registerCleanupFunction(async () => {
+ await resetEngines();
+ });
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME,
+ });
+});
+
+async function testSearchBarChangeEngine(win, testPrivate, isPrivateWindow) {
+ info(
+ `Testing search bar with testPrivate: ${testPrivate} isPrivateWindow: ${isPrivateWindow}`
+ );
+
+ const searchPopup = win.document.getElementById("PopupSearchAutoComplete");
+ const searchOneOff = searchPopup.oneOffButtons;
+
+ // Ensure the engine is reset.
+ await resetEngines();
+
+ let oneOffButton = await openPopupAndGetEngineButton(
+ searchPopup,
+ searchOneOff,
+ SEARCHBAR_BASE_ID,
+ TEST_ENGINE_NAME
+ );
+
+ const contextMenu = searchOneOff.contextMenuPopup;
+ const setDefaultEngineMenuItem = searchOneOff.querySelector(
+ ".search-one-offs-context-set-default" + (testPrivate ? "-private" : "")
+ );
+
+ // Click the set default engine menu item.
+ let promise = promiseDefaultEngineChanged(testPrivate);
+ contextMenu.activateItem(setDefaultEngineMenuItem);
+
+ // This also checks the engine correctly changed.
+ await promise;
+
+ if (testPrivate == isPrivateWindow) {
+ let expectedName = originalEngine.name;
+ let expectedImage = originalEngine.iconURI.spec;
+ if (isPrivateWindow) {
+ expectedName = originalPrivateEngine.name;
+ expectedImage = originalPrivateEngine.iconURI.spec;
+ }
+
+ Assert.equal(
+ oneOffButton.getAttribute("tooltiptext"),
+ expectedName,
+ "Should now have the original engine's name for the tooltip"
+ );
+ Assert.equal(
+ oneOffButton.image,
+ expectedImage,
+ "Should now have the original engine's uri for the image"
+ );
+ }
+
+ await promiseClosePopup(searchPopup);
+}
+
+add_task(async function test_searchBarChangeEngine() {
+ await testSearchBarChangeEngine(window, false, false);
+ await testSearchBarChangeEngine(window, true, false);
+});
+
+add_task(async function test_searchBarChangeEngine_privateWindow() {
+ const win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await testSearchBarChangeEngine(win, true, true);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Promises that an engine change has happened for the current engine, which
+ * has resulted in the test engine now being the current engine.
+ *
+ * @param {boolean} testPrivate
+ * Set to true if we're testing the private default engine.
+ * @returns {Promise} Resolved once the test engine is set as the current engine.
+ */
+function promiseDefaultEngineChanged(testPrivate) {
+ const expectedNotification = testPrivate
+ ? "engine-default-private"
+ : "engine-default";
+ return new Promise(resolve => {
+ function observer(aSub, aTopic, aData) {
+ if (aData == expectedNotification) {
+ Assert.equal(
+ Services.search[
+ testPrivate ? "defaultPrivateEngine" : "defaultEngine"
+ ].name,
+ TEST_ENGINE_NAME,
+ "defaultEngine set"
+ );
+ Services.obs.removeObserver(observer, "browser-search-engine-modified");
+ resolve();
+ }
+ }
+
+ Services.obs.addObserver(observer, "browser-search-engine-modified");
+ });
+}
+
+/**
+ * Opens the specified search popup and gets the test engine from the
+ * one-off buttons.
+ *
+ * @param {object} popup The expected popup.
+ * @param {object} oneOffInstance The expected one-off instance for the popup.
+ * @param {string} baseId The expected string for the id of the current
+ * engine button, without the engine name.
+ * @param {string} engineName The engine name for finding the one-off button.
+ * @returns {object} Returns an object that represents the one off button for the
+ * test engine.
+ */
+async function openPopupAndGetEngineButton(
+ popup,
+ oneOffInstance,
+ baseId,
+ engineName
+) {
+ const win = oneOffInstance.container.ownerGlobal;
+ // Open the popup.
+ win.gURLBar.blur();
+ let shownPromise = promiseEvent(popup, "popupshown");
+ let builtPromise = promiseEvent(oneOffInstance, "rebuild");
+ let searchbar = win.document.getElementById("searchbar");
+ let searchIcon = searchbar.querySelector(".searchbar-search-button");
+ // Use the search icon to avoid hitting the network.
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {}, win);
+ await Promise.all([shownPromise, builtPromise]);
+
+ const contextMenu = oneOffInstance.contextMenuPopup;
+ let oneOffButton = oneOffInstance.buttons;
+
+ // Get the one-off button for the test engine.
+ for (
+ oneOffButton = oneOffButton.firstChild;
+ oneOffButton;
+ oneOffButton = oneOffButton.nextSibling
+ ) {
+ if (
+ oneOffButton.nodeType == Node.ELEMENT_NODE &&
+ oneOffButton.engine &&
+ oneOffButton.engine.name == engineName
+ ) {
+ break;
+ }
+ }
+
+ Assert.notEqual(
+ oneOffButton,
+ undefined,
+ "One-off for test engine should exist"
+ );
+ Assert.equal(
+ oneOffButton.getAttribute("tooltiptext"),
+ engineName,
+ "One-off should have the tooltip set to the engine name"
+ );
+
+ Assert.ok(
+ oneOffButton.id.startsWith(baseId + "engine-"),
+ "Should have an appropriate id"
+ );
+
+ // Open the context menu on the one-off.
+ let promise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ oneOffButton,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ win
+ );
+ await promise;
+
+ return oneOffButton;
+}
+
+/**
+ * Closes the popup and moves the mouse away from it.
+ *
+ * @param {Button} popup The popup to close.
+ */
+async function promiseClosePopup(popup) {
+ // close the panel using the escape key.
+ let promise = promiseEvent(popup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape", {}, popup.ownerGlobal);
+ await promise;
+
+ // Move the cursor out of the panel area to avoid messing with other tests.
+ EventUtils.synthesizeNativeMouseEvent({
+ type: "mousemove",
+ target: popup,
+ offsetX: 0,
+ offsetY: 0,
+ win: popup.ownerGlobal,
+ });
+}
diff --git a/browser/components/search/test/browser/browser_private_search_perwindowpb.js b/browser/components/search/test/browser/browser_private_search_perwindowpb.js
new file mode 100644
index 0000000000..b1ca3cb962
--- /dev/null
+++ b/browser/components/search/test/browser/browser_private_search_perwindowpb.js
@@ -0,0 +1,84 @@
+// This test performs a search in a public window, then a different
+// search in a private window, and then checks in the public window
+// whether there is an autocomplete entry for the private search.
+
+add_task(async function test_setup() {
+ await gCUITestUtils.addSearchBar();
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "426329.xml",
+ setAsDefault: true,
+ });
+
+ registerCleanupFunction(async () => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function () {
+ let windowsToClose = [];
+
+ function performSearch(aWin, aIsPrivate) {
+ let searchBar = aWin.BrowserSearch.searchBar;
+ ok(searchBar, "got search bar");
+
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ aWin.gBrowser.selectedBrowser
+ );
+
+ searchBar.value = aIsPrivate ? "private test" : "public test";
+ searchBar.focus();
+ EventUtils.synthesizeKey("VK_RETURN", {}, aWin);
+
+ return loadPromise;
+ }
+
+ async function testOnWindow(aIsPrivate) {
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: aIsPrivate,
+ });
+ await SimpleTest.promiseFocus(win);
+ windowsToClose.push(win);
+ return win;
+ }
+
+ let newWindow = await testOnWindow(false);
+ await performSearch(newWindow, false);
+
+ newWindow = await testOnWindow(true);
+ await performSearch(newWindow, true);
+
+ newWindow = await testOnWindow(false);
+
+ let searchBar = newWindow.BrowserSearch.searchBar;
+ searchBar.value = "p";
+ searchBar.focus();
+
+ let popup = searchBar.textbox.popup;
+ let popupPromise = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ searchBar.textbox.showHistoryPopup();
+ await popupPromise;
+
+ let entries = getMenuEntries(searchBar);
+ for (let i = 0; i < entries.length; i++) {
+ isnot(
+ entries[i],
+ "private test",
+ "shouldn't see private autocomplete entries"
+ );
+ }
+
+ searchBar.textbox.toggleHistoryPopup();
+ searchBar.value = "";
+
+ windowsToClose.forEach(function (win) {
+ win.close();
+ });
+});
+
+function getMenuEntries(searchBar) {
+ // Could perhaps pull values directly from the controller, but it seems
+ // more reliable to test the values that are actually in the richlistbox?
+ return Array.from(searchBar.textbox.popup.richlistbox.itemChildren, item =>
+ item.getAttribute("ac-value")
+ );
+}
diff --git a/browser/components/search/test/browser/browser_rich_suggestions.js b/browser/components/search/test/browser/browser_rich_suggestions.js
new file mode 100644
index 0000000000..b92cdb5a6a
--- /dev/null
+++ b/browser/components/search/test/browser/browser_rich_suggestions.js
@@ -0,0 +1,110 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CONFIG_DEFAULT = [
+ {
+ webExtension: { id: "basic@search.mozilla.org" },
+ urls: {
+ trending: {
+ fullPath:
+ "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs?richsuggestions=true",
+ query: "",
+ },
+ },
+ appliesTo: [{ included: { everywhere: true } }],
+ default: "yes",
+ },
+];
+
+SearchTestUtils.init(this);
+
+add_setup(async () => {
+ // Use engines in test directory
+ let searchExtensions = getChromeDir(getResolvedURI(gTestPath));
+ searchExtensions.append("search-engines");
+ await SearchTestUtils.useMochitestEngines(searchExtensions);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.suggest.searches", true],
+ ["browser.urlbar.trending.featureGate", true],
+ ["browser.urlbar.trending.requireSearchMode", false],
+ ["browser.urlbar.eventTelemetry.enabled", true],
+ // Bug 1775917: Disable the persisted-search-terms search tip because if
+ // not dismissed, it can cause issues with other search tests.
+ ["browser.urlbar.tipShownCount.searchTip_persist", 999],
+ ],
+ });
+
+ SearchTestUtils.useMockIdleService();
+ await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT);
+
+ registerCleanupFunction(async () => {
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await SearchTestUtils.updateRemoteSettingsConfig();
+ await settingsWritten;
+ });
+});
+
+add_task(async function test_trending_results() {
+ await check_results({ featureEnabled: true });
+ await check_results({ featureEnabled: false });
+});
+
+async function check_results({ featureEnabled = false }) {
+ Services.telemetry.clearEvents();
+ Services.telemetry.clearScalars();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.richSuggestions.featureGate", featureEnabled]],
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "",
+ waitForFocus: SimpleTest.waitForFocus,
+ });
+
+ let numResults = UrlbarTestUtils.getResultCount(window);
+
+ for (let i = 0; i < numResults; i++) {
+ let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH);
+ Assert.equal(result.providerName, "SearchSuggestions");
+ Assert.equal(result.payload.engine, "basic");
+ Assert.equal(result.payload.isRichSuggestion, featureEnabled);
+ if (featureEnabled) {
+ Assert.equal(typeof result.payload.description, "string");
+ Assert.ok(result.payload.icon.startsWith("data:"));
+ }
+ }
+
+ info("Select first remote search suggestion & hit Enter.");
+ EventUtils.synthesizeKey("KEY_ArrowDown", {}, window);
+ EventUtils.synthesizeKey("VK_RETURN", {}, window);
+
+ let event = {
+ category: "urlbar",
+ method: "engagement",
+ object: "enter",
+ value: "typed",
+ extra: {
+ elapsed: val => parseInt(val) > 0,
+ numChars: "0",
+ numWords: "0",
+ selIndex: "0",
+ selType: featureEnabled ? "trending_rich" : "trending",
+ provider: "SearchSuggestions",
+ },
+ };
+
+ TelemetryTestUtils.assertEvents([event], {
+ category: "urlbar",
+ });
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ TelemetryTestUtils.assertScalar(scalars, "urlbar.engagement", 1);
+
+ await SpecialPowers.popPrefEnv();
+}
diff --git a/browser/components/search/test/browser/browser_searchEngine_behaviors.js b/browser/components/search/test/browser/browser_searchEngine_behaviors.js
new file mode 100644
index 0000000000..4303249e63
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchEngine_behaviors.js
@@ -0,0 +1,223 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Test search plugin URLs
+ */
+
+"use strict";
+
+const SEARCH_ENGINE_DETAILS = [
+ {
+ alias: "a",
+ baseURL:
+ "https://www.amazon.com/exec/obidos/external-search/?field-keywords=foo&ie=UTF-8&mode=blended&tag=moz-us-20&sourceid=Mozilla-search",
+ codes: {
+ context: "",
+ keyword: "",
+ newTab: "",
+ submission: "",
+ },
+ name: "Amazon.com",
+ },
+ {
+ alias: "b",
+ baseURL: `https://www.bing.com/search?{code}pc=${
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "MOZR" : "MOZI"
+ }&q=foo`,
+ codes: {
+ context: "form=MOZCON&",
+ keyword: "form=MOZLBR&",
+ newTab: "form=MOZTSB&",
+ submission: "form=MOZSBR&",
+ },
+ name: "Bing",
+ },
+ {
+ alias: "d",
+ baseURL: `https://duckduckgo.com/?{code}t=${
+ SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "ftsa" : "ffab"
+ }&q=foo`,
+ codes: {
+ context: "",
+ keyword: "",
+ newTab: "",
+ submission: "",
+ },
+ name: "DuckDuckGo",
+ },
+ {
+ alias: "e",
+ baseURL:
+ "https://www.ebay.com/sch/?toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=711-53200-19255-0&kw=foo",
+ codes: {
+ context: "",
+ keyword: "",
+ newTab: "",
+ submission: "",
+ },
+ name: "eBay",
+ },
+ // {
+ // TODO: Google is tested in browser_google_behaviors.js - we can't test it here
+ // yet because of bug 1315953.
+ // alias: "g",
+ // baseURL: "https://www.google.com/search?q=foo&ie=utf-8&oe=utf-8",
+ // codes: {
+ // context: "",
+ // keyword: "",
+ // newTab: "",
+ // submission: "",
+ // },
+ // name: "Google",
+ // },
+];
+
+function promiseContentSearchReady(browser) {
+ return SpecialPowers.spawn(browser, [], async function (args) {
+ SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.wrappedJSObject.gContentSearchController &&
+ content.wrappedJSObject.gContentSearchController.defaultEngine
+ );
+ });
+}
+
+add_task(async function test_setup() {
+ await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+for (let engine of SEARCH_ENGINE_DETAILS) {
+ add_task(async function () {
+ let previouslySelectedEngine = await Services.search.getDefault();
+
+ registerCleanupFunction(async function () {
+ await Services.search.setDefault(
+ previouslySelectedEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ });
+
+ await testSearchEngine(engine);
+ });
+}
+
+async function testSearchEngine(engineDetails) {
+ let engine = Services.search.getEngineByName(engineDetails.name);
+ Assert.ok(engine, `${engineDetails.name} is installed`);
+
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ engine.alias = engineDetails.alias;
+
+ let base = engineDetails.baseURL;
+
+ // Test search URLs (including purposes).
+ let url = engine.getSubmission("foo").uri.spec;
+ Assert.equal(
+ url,
+ base.replace("{code}", engineDetails.codes.submission),
+ "Check search URL for 'foo'"
+ );
+ let sb = BrowserSearch.searchBar;
+
+ let engineTests = [
+ {
+ name: "context menu search",
+ searchURL: base.replace("{code}", engineDetails.codes.context),
+ run() {
+ // Simulate a contextmenu search
+ // FIXME: This is a bit "low-level"...
+ BrowserSearch._loadSearch(
+ "foo",
+ false,
+ false,
+ "contextmenu",
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ },
+ },
+ {
+ name: "keyword search",
+ searchURL: base.replace("{code}", engineDetails.codes.keyword),
+ run() {
+ gURLBar.value = "? foo";
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ },
+ {
+ name: "keyword search with alias",
+ searchURL: base.replace("{code}", engineDetails.codes.keyword),
+ run() {
+ gURLBar.value = `${engineDetails.alias} foo`;
+ gURLBar.focus();
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ },
+ {
+ name: "search bar search",
+ searchURL: base.replace("{code}", engineDetails.codes.submission),
+ run() {
+ sb.focus();
+ sb.value = "foo";
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ },
+ {
+ name: "new tab search",
+ searchURL: base.replace("{code}", engineDetails.codes.newTab),
+ async preTest(tab) {
+ let browser = tab.linkedBrowser;
+ BrowserTestUtils.loadURIString(browser, "about:newtab");
+
+ await BrowserTestUtils.browserLoaded(browser, false, "about:newtab");
+ await promiseContentSearchReady(browser);
+ },
+ async run(tab) {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ let input = content.document.querySelector("input[id*=search-]");
+ input.focus();
+ input.value = "foo";
+ });
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ },
+ ];
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ for (let test of engineTests) {
+ info(`Running: ${test.name}`);
+
+ if (test.preTest) {
+ await test.preTest(tab);
+ }
+
+ let promises = [
+ BrowserTestUtils.waitForDocLoadAndStopIt(test.searchURL, tab),
+ BrowserTestUtils.browserStopped(tab.linkedBrowser, test.searchURL, true),
+ ];
+
+ await test.run(tab);
+
+ await Promise.all(promises);
+ }
+
+ engine.alias = undefined;
+ sb.value = "";
+ BrowserTestUtils.removeTab(tab);
+}
diff --git a/browser/components/search/test/browser/browser_search_annotation.js b/browser/components/search/test/browser/browser_search_annotation.js
new file mode 100644
index 0000000000..991646657e
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_annotation.js
@@ -0,0 +1,176 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test whether a visit information is annotated correctly when searching on searchbar.
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+});
+
+const FRECENCY = {
+ SEARCHED: 100,
+ BOOKMARKED: 175,
+};
+
+const { VISIT_SOURCE_BOOKMARKED, VISIT_SOURCE_SEARCHED } = PlacesUtils.history;
+
+async function assertDatabase({ targetURL, expected }) {
+ const frecency = await PlacesTestUtils.getDatabaseValue(
+ "moz_places",
+ "frecency",
+ { url: targetURL }
+ );
+ Assert.equal(frecency, expected.frecency, "Frecency is correct");
+
+ const placesId = await PlacesTestUtils.getDatabaseValue("moz_places", "id", {
+ url: targetURL,
+ });
+ const db = await PlacesUtils.promiseDBConnection();
+ const rows = await db.execute(
+ "SELECT source, triggeringPlaceId FROM moz_historyvisits WHERE place_id = :place_id AND source = :source",
+ {
+ place_id: placesId,
+ source: expected.source,
+ }
+ );
+ Assert.equal(rows.length, 1);
+ Assert.equal(
+ rows[0].getResultByName("triggeringPlaceId"),
+ null,
+ `The triggeringPlaceId in database is correct for ${targetURL}`
+ );
+}
+
+add_setup(async function () {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ await gCUITestUtils.addSearchBar();
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "Example",
+ keyword: "@test",
+ },
+ { setAsDefault: true }
+ );
+
+ registerCleanupFunction(async function () {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function basic() {
+ const testData = [
+ {
+ description: "Normal search",
+ input: "abc",
+ resultURL: "https://example.com/?q=abc",
+ expected: {
+ source: VISIT_SOURCE_SEARCHED,
+ frecency: FRECENCY.SEARCHED,
+ },
+ },
+ {
+ description: "Search but the url is bookmarked",
+ input: "abc",
+ resultURL: "https://example.com/?q=abc",
+ bookmarks: [
+ {
+ parentGuid: PlacesUtils.bookmarks.toolbarGuid,
+ url: Services.io.newURI("https://example.com/?q=abc"),
+ title: "test bookmark",
+ },
+ ],
+ expected: {
+ source: VISIT_SOURCE_BOOKMARKED,
+ frecency: FRECENCY.BOOKMARKED,
+ },
+ },
+ ];
+
+ for (const {
+ description,
+ input,
+ resultURL,
+ bookmarks,
+ expected,
+ } of testData) {
+ info(description);
+
+ for (const bookmark of bookmarks || []) {
+ await PlacesUtils.bookmarks.insert(bookmark);
+ }
+
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ resultURL
+ );
+ await searchInSearchbar(input);
+ let promiseVisited = PlacesTestUtils.waitForNotification(
+ "page-visited",
+ events => events.some(e => e.url == resultURL)
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ await onLoad;
+ await promiseVisited;
+ await assertDatabase({ targetURL: resultURL, expected });
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ }
+});
+
+add_task(async function contextmenu() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/browser/browser/components/search/test/browser/test_search.html",
+ async () => {
+ // Select html content.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ await new Promise(resolve => {
+ content.document.addEventListener("selectionchange", resolve, {
+ once: true,
+ });
+ content.document
+ .getSelection()
+ .selectAllChildren(content.document.body);
+ });
+ });
+
+ const onPopup = BrowserTestUtils.waitForEvent(document, "popupshown");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#id",
+ { type: "contextmenu" },
+ gBrowser.selectedBrowser
+ );
+ await onPopup;
+
+ const targetURL = "https://example.com/?q=test%2520search";
+ const onLoad = BrowserTestUtils.waitForNewTab(gBrowser, targetURL, true);
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ const openLinkMenuItem = contextMenu.querySelector(
+ "#context-searchselect"
+ );
+ let promiseVisited = PlacesTestUtils.waitForNotification(
+ "page-visited",
+ events => events.some(e => e.url == targetURL)
+ );
+ contextMenu.activateItem(openLinkMenuItem);
+ const tab = await onLoad;
+ await promiseVisited;
+ await assertDatabase({
+ targetURL,
+ expected: {
+ source: VISIT_SOURCE_SEARCHED,
+ frecency: FRECENCY.SEARCHED,
+ },
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ }
+ );
+});
diff --git a/browser/components/search/test/browser/browser_search_discovery.js b/browser/components/search/test/browser/browser_search_discovery.js
new file mode 100644
index 0000000000..94c198776c
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_discovery.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+// Bug 1588193 - BrowserTestUtils.waitForContentEvent now resolves slightly
+// earlier than before, so it no longer suffices to only wait for a single event
+// tick before checking if browser.engines has been updated. Instead we use a 1s
+// timeout, which may cause the test to take more time.
+requestLongerTimeout(2);
+
+add_task(async function () {
+ let url =
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/discovery.html";
+ info("Test search discovery");
+ await BrowserTestUtils.withNewTab(url, searchDiscovery);
+});
+
+let searchDiscoveryTests = [
+ { text: "rel search discovered" },
+ { rel: "SEARCH", text: "rel is case insensitive" },
+ { rel: "-search-", pass: false, text: "rel -search- not discovered" },
+ {
+ rel: "foo bar baz search quux",
+ text: "rel may contain additional rels separated by spaces",
+ },
+ { href: "https://not.mozilla.com", text: "HTTPS ok" },
+ { href: "data:text/foo,foo", pass: false, text: "data URI not permitted" },
+ { href: "javascript:alert(0)", pass: false, text: "JS URI not permitted" },
+ {
+ type: "APPLICATION/OPENSEARCHDESCRIPTION+XML",
+ text: "type is case insensitve",
+ },
+ {
+ type: " application/opensearchdescription+xml ",
+ text: "type may contain extra whitespace",
+ },
+ {
+ type: "application/opensearchdescription+xml; charset=utf-8",
+ text: "type may have optional parameters (RFC2046)",
+ },
+ {
+ type: "aapplication/opensearchdescription+xml",
+ pass: false,
+ text: "type should not be loosely matched",
+ },
+ {
+ rel: "search search search",
+ count: 1,
+ text: "only one engine should be added",
+ },
+];
+
+async function searchDiscovery() {
+ let browser = gBrowser.selectedBrowser;
+
+ for (let testCase of searchDiscoveryTests) {
+ if (testCase.pass == undefined) {
+ testCase.pass = true;
+ }
+ testCase.title = testCase.title || searchDiscoveryTests.indexOf(testCase);
+
+ let promiseLinkAdded = BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "DOMLinkAdded",
+ false,
+ null,
+ true
+ );
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [testCase], test => {
+ let doc = content.document;
+ let head = doc.getElementById("linkparent");
+ let link = doc.createElement("link");
+ link.rel = test.rel || "search";
+ link.href = test.href || "http://so.not.here.mozilla.com/search.xml";
+ link.type = test.type || "application/opensearchdescription+xml";
+ link.title = test.title;
+ head.appendChild(link);
+ });
+
+ await promiseLinkAdded;
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ if (browser.engines) {
+ info(`Found ${browser.engines.length} engines`);
+ info(`First engine title: ${browser.engines[0].title}`);
+ let hasEngine = testCase.count
+ ? browser.engines[0].title == testCase.title &&
+ browser.engines.length == testCase.count
+ : browser.engines[0].title == testCase.title;
+ ok(hasEngine, testCase.text);
+ browser.engines = null;
+ } else {
+ ok(!testCase.pass, testCase.text);
+ }
+ }
+
+ info("Test multiple engines with the same title");
+ let promiseLinkAdded = BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "DOMLinkAdded",
+ false,
+ e => e.target.href == "http://second.mozilla.com/search.xml",
+ true
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let doc = content.document;
+ let head = doc.getElementById("linkparent");
+ let link = doc.createElement("link");
+ link.rel = "search";
+ link.href = "http://first.mozilla.com/search.xml";
+ link.type = "application/opensearchdescription+xml";
+ link.title = "Test Engine";
+ let link2 = link.cloneNode(false);
+ link2.href = "http://second.mozilla.com/search.xml";
+ head.appendChild(link);
+ head.appendChild(link2);
+ });
+
+ await promiseLinkAdded;
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ ok(browser.engines, "has engines");
+ is(browser.engines.length, 1, "only one engine");
+ is(
+ browser.engines[0].uri,
+ "http://first.mozilla.com/search.xml",
+ "first engine wins"
+ );
+ browser.engines = null;
+}
diff --git a/browser/components/search/test/browser/browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js
new file mode 100644
index 0000000000..99be6ca76b
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js
@@ -0,0 +1,159 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test to verify we can toggle the Glean SERP telemetry feature via a Nimbus
+// variable.
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "serpEventsEnabled",
+ "browser.search.serpEventTelemetry.enabled",
+ false
+);
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+async function verifyEventsRecorded() {
+ function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+ }
+
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd.html")
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ await waitForPageWithAdImpressions();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+
+ assertAbandonmentEvent({
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ });
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.log", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ await SpecialPowers.popPrefEnv();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_enable_experiment() {
+ Assert.equal(
+ lazy.serpEventsEnabled,
+ false,
+ "serpEventsEnabled should be false when not enrolled in experiment."
+ );
+
+ await lazy.ExperimentAPI.ready();
+
+ let doExperimentCleanup = await lazy.ExperimentFakes.enrollWithFeatureConfig(
+ {
+ featureId: NimbusFeatures.search.featureId,
+ value: {
+ serpEventTelemetryEnabled: true,
+ },
+ },
+ { isRollout: true }
+ );
+
+ Assert.equal(
+ lazy.serpEventsEnabled,
+ true,
+ "serpEventsEnabled should be true when enrolled in experiment."
+ );
+
+ // To ensure Nimbus set "browser.search.serpEventTelemetry.enabled" to true,
+ // we test that an impression, ad_impression and abandonment event are
+ // recorded correctly.
+ await verifyEventsRecorded();
+
+ await doExperimentCleanup();
+
+ Assert.equal(
+ lazy.serpEventsEnabled,
+ false,
+ "serpEventsEnabled should be false after experiment."
+ );
+});
diff --git a/browser/components/search/test/browser/browser_search_nimbus_reload.js b/browser/components/search/test/browser/browser_search_nimbus_reload.js
new file mode 100644
index 0000000000..19247c9a02
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_nimbus_reload.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+const { SearchService } = ChromeUtils.importESModule(
+ "resource://gre/modules/SearchService.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+add_task(async function test_engines_reloaded_nimbus() {
+ let reloadSpy = sinon.spy(SearchService.prototype, "_maybeReloadEngines");
+ let getVariableSpy = sinon.spy(
+ NimbusFeatures.searchConfiguration,
+ "getVariable"
+ );
+
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "searchConfiguration",
+ value: { experiment: "nimbus-search-mochitest" },
+ });
+
+ Assert.equal(reloadSpy.callCount, 1, "Called by experiment enrollment");
+ await BrowserTestUtils.waitForCondition(
+ () => getVariableSpy.calledWith("experiment"),
+ "Wait for SearchService update to run"
+ );
+ Assert.equal(
+ getVariableSpy.callCount,
+ 3,
+ "Called by update function to fetch engines"
+ );
+ Assert.ok(
+ getVariableSpy.calledWith("experiment"),
+ "Called by search service observer"
+ );
+ Assert.equal(
+ NimbusFeatures.searchConfiguration.getVariable("experiment"),
+ "nimbus-search-mochitest",
+ "Should have expected value"
+ );
+
+ await doExperimentCleanup();
+
+ Assert.equal(reloadSpy.callCount, 2, "Called by experiment unenrollment");
+
+ reloadSpy.restore();
+ getVariableSpy.restore();
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_abandonment.js b/browser/components/search/test/browser/browser_search_telemetry_abandonment.js
new file mode 100644
index 0000000000..f599ad79f9
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_abandonment.js
@@ -0,0 +1,243 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for the Glean SERP abandonment event
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_tab_close() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetry.html")
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ assertAbandonmentEvent({
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ });
+});
+
+add_task(async function test_window_close() {
+ resetTelemetry();
+
+ let serpUrl = getSERPUrl("searchTelemetry.html");
+ let otherWindow = await BrowserTestUtils.openNewBrowserWindow();
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ otherWindow.gBrowser,
+ false,
+ serpUrl
+ );
+ BrowserTestUtils.loadURIString(otherWindow.gBrowser, serpUrl);
+ await browserLoadedPromise;
+
+ await BrowserTestUtils.closeWindow(otherWindow);
+
+ assertAbandonmentEvent({
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.WINDOW_CLOSE,
+ },
+ });
+});
+
+add_task(async function test_navigation_via_urlbar() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetry.html")
+ );
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser,
+ false,
+ "https://www.example.com/"
+ );
+ BrowserTestUtils.loadURIString(gBrowser, "https://www.example.com");
+ await browserLoadedPromise;
+
+ assertAbandonmentEvent({
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ });
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_navigation_via_back_button() {
+ resetTelemetry();
+
+ let exampleUrl = "https://example.com/";
+ let serpUrl = getSERPUrl("searchTelemetry.html");
+ await BrowserTestUtils.withNewTab(exampleUrl, async browser => {
+ info("example.com is now loaded.");
+
+ let pageLoadPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ serpUrl
+ );
+ BrowserTestUtils.loadURIString(browser, serpUrl);
+ await pageLoadPromise;
+ info("Serp is now loaded.");
+
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "pageshow"
+ );
+ browser.goBack();
+ await pageShowPromise;
+
+ info("Previous page (example.com) is now loaded after back navigation.");
+ });
+
+ assertAbandonmentEvent({
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ });
+});
+
+add_task(async function test_click_ad() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd.html")
+ );
+
+ await TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a",
+ {},
+ gBrowser.selectedBrowser
+ );
+ await browserLoadedPromise;
+
+ Assert.equal(
+ !!Glean.serp.abandonment.testGetValue(),
+ false,
+ "Should not have any abandonment events."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_without_components() {
+ // Mock a provider that doesn't have components.
+ let providerInfo = [
+ {
+ ...TEST_PROVIDER_INFO[0],
+ components: [],
+ },
+ ];
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(providerInfo);
+ await waitForIdle();
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd.html")
+ );
+
+ // We shouldn't expect a SERP impression, so instead wait roughly
+ // around how long it would usually take to receive an impression following
+ // a page load.
+ await promiseWaitForAdLinkCheck();
+ Assert.equal(
+ !!Glean.serp.impression.testGetValue(),
+ false,
+ "Should not have any impression events."
+ );
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser,
+ false,
+ "https://www.example.com/"
+ );
+ BrowserTestUtils.loadURIString(gBrowser, "https://www.example.com");
+ await browserLoadedPromise;
+
+ Assert.equal(
+ !!Glean.serp.abandonment.testGetValue(),
+ false,
+ "Should not have any abandonment events."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Allow subsequent tests to use the default provider.
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_aboutHome.js b/browser/components/search/test/browser/browser_search_telemetry_aboutHome.js
new file mode 100644
index 0000000000..702cbabb0f
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_aboutHome.js
@@ -0,0 +1,135 @@
+"use strict";
+
+const SCALAR_ABOUT_HOME = "browser.engagement.navigation.about_home";
+
+add_setup(async function () {
+ // about:home uses IndexedDB. However, the test finishes too quickly and doesn't
+ // allow it enougth time to save. So it throws. This disables all the uncaught
+ // exception in this file and that's the reason why we split about:home tests
+ // out of the other UsageTelemetry files.
+ ignoreAllUncaughtExceptions();
+
+ // Create two new search engines. Mark one as the default engine, so
+ // the test don't crash. We need to engines for this test as the searchbar
+ // in content doesn't display the default search engine among the one-off engines.
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ keyword: "mozalias",
+ },
+ { setAsDefault: true }
+ );
+ await SearchTestUtils.installSearchExtension({
+ name: "MozSearch2",
+ keyword: "mozalias2",
+ });
+
+ // Move the second engine at the beginning of the one-off list.
+ let engineOneOff = Services.search.getEngineByName("MozSearch2");
+ await Services.search.moveEngine(engineOneOff, 0);
+
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ // Enable event recording for the events tested here.
+ Services.telemetry.setEventRecordingEnabled("navigation", true);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.history.clear();
+ Services.telemetry.setEventRecordingEnabled("navigation", false);
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+});
+
+add_task(async function test_abouthome_activitystream_simpleQuery() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ Services.fog.testResetFOG();
+ let search_hist =
+ TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ info("Load about:home.");
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:home");
+ await BrowserTestUtils.browserStopped(tab.linkedBrowser, "about:home");
+
+ info("Wait for ContentSearchUI search provider to initialize.");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(
+ () => content.wrappedJSObject.gContentSearchController.defaultEngine
+ );
+ });
+
+ info("Trigger a simple search, just test + enter.");
+ let p = BrowserTestUtils.browserStopped(
+ tab.linkedBrowser,
+ "https://example.com/?q=test+query"
+ );
+ await typeInSearchField(
+ tab.linkedBrowser,
+ "test query",
+ "newtab-search-text"
+ );
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser);
+ await p;
+
+ // Check if the scalars contain the expected values.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ SCALAR_ABOUT_HOME,
+ "search_enter",
+ 1
+ );
+ Assert.equal(
+ Object.keys(scalars[SCALAR_ABOUT_HOME]).length,
+ 1,
+ "This search must only increment one entry in the scalar."
+ );
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ TelemetryTestUtils.assertKeyedHistogramSum(
+ search_hist,
+ "other-MozSearch.abouthome",
+ 1
+ );
+
+ // Also check events.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "about_home",
+ value: "enter",
+ extra: { engine: "other-MozSearch" },
+ },
+ ],
+ { category: "navigation", method: "search" }
+ );
+
+ // Also also check Glean events.
+ const record = Glean.newtabSearch.issued.testGetValue();
+ Assert.ok(!!record, "Must have recorded a search issuance");
+ Assert.equal(record.length, 1, "One search, one event");
+ Assert.deepEqual(
+ {
+ search_access_point: "about_home",
+ telemetry_id: "other-MozSearch",
+ },
+ record[0].extra,
+ "Must have recorded the expected information."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_adImpression_component.js b/browser/components/search/test/browser/browser_search_telemetry_adImpression_component.js
new file mode 100644
index 0000000000..c9a4c6a8be
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_adImpression_component.js
@@ -0,0 +1,401 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const WINDOW_HEIGHT = 768;
+const WINDOW_WIDTH = 1024;
+
+// Note: example.org is used for the SERP page, and example.com is used to serve
+// the ads. This is done to simulate different domains like the real servers.
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ included: {
+ parent: {
+ selector: ".moz-carousel",
+ },
+ children: [
+ {
+ selector: ".moz-carousel-card",
+ countChildren: true,
+ },
+ ],
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ included: {
+ parent: {
+ selector: ".refined-search-buttons",
+ },
+ children: [
+ {
+ selector: "a",
+ },
+ ],
+ },
+ topDown: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ included: {
+ parent: {
+ selector: ".moz_ad",
+ },
+ children: [
+ {
+ selector: ".multi-col",
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ },
+ excluded: {
+ parent: {
+ selector: ".rhs",
+ },
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_SIDEBAR,
+ included: {
+ parent: {
+ selector: ".rhs",
+ },
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+async function promiseAdImpressionReceived() {
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+}
+
+async function promiseResize(width, height) {
+ return TestUtils.waitForCondition(() => {
+ return window.outerWidth === width && window.outerHeight === height;
+ }, "Waiting for window to resize");
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ // The tests evaluate whether or not ads are visible depending on whether
+ // they are within the view of the window. To ensure the test results
+ // are consistent regardless of where they are launched,
+ // set the window size to something reasonable.
+ let originalWidth = window.outerWidth;
+ let originalHeight = window.outerHeight;
+ window.resizeTo(WINDOW_WIDTH, WINDOW_HEIGHT);
+ await promiseResize(WINDOW_WIDTH, WINDOW_HEIGHT);
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ window.resizeTo(originalWidth, originalHeight);
+ await promiseResize(originalWidth, originalHeight);
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_ad_impressions_with_one_carousel() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_carousel.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "4",
+ ads_visible: "3",
+ ads_hidden: "0",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This is to ensure we're not counting two carousel components as two
+// separate components but as one record with a sum of the results.
+add_task(async function test_ad_impressions_with_two_carousels() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_carousel_doubled.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ // This is to ensure we've seen the other carousel regardless the
+ // size of the browser window.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let el = content.document
+ .getElementById("second-ad")
+ .getBoundingClientRect();
+ // The 100 is just to guarantee we've scrolled past the element.
+ content.scrollTo(0, el.top + el.height + 100);
+ });
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "8",
+ ads_visible: "6",
+ ads_hidden: "0",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(
+ async function test_ad_impressions_with_carousels_with_outer_container() {
+ resetTelemetry();
+ let url = getSERPUrl(
+ "searchTelemetryAd_components_carousel_outer_container.html"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "4",
+ ads_visible: "3",
+ ads_hidden: "0",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ }
+);
+
+add_task(async function test_ad_impressions_with_carousels_tabhistory() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_carousel.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ // Reset telemetry because we care about the telemetry upon going back.
+ resetTelemetry();
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ "https://www.example.com/some_url"
+ );
+ await browserLoadedPromise;
+
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "pageshow"
+ );
+ tab.linkedBrowser.goBack();
+ await pageShowPromise;
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "4",
+ ads_visible: "3",
+ ads_hidden: "0",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_ad_impressions_with_hidden_carousels() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_carousel_hidden.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "4",
+ ads_visible: "0",
+ ads_hidden: "4",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_ad_impressions_with_carousel_scrolled_left() {
+ resetTelemetry();
+ let url = getSERPUrl(
+ "searchTelemetryAd_components_carousel_first_element_non_visible.html"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "4",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_ad_impressions_with_carousel_below_the_fold() {
+ resetTelemetry();
+ let url = getSERPUrl(
+ "searchTelemetryAd_components_carousel_below_the_fold.html"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "4",
+ ads_visible: "0",
+ ads_hidden: "0",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_ad_impressions_with_text_links() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SIDEBAR,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ]);
+ BrowserTestUtils.removeTab(tab);
+});
+
+// An ad is considered visible if at least one link is within the viewable
+// content area when the impression was taken. Since the user can scroll
+// the page before ad impression is recorded, we should ensure that an
+// ad that was scrolled onto the screen before the impression is taken is
+// properly recorded. Additionally, some ads might have a large content
+// area that extends beyond the viewable area, but as long as a single
+// ad link was viewable within the area, we should count the ads as visible.
+add_task(async function test_ad_visibility() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_visibility.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let el = content.document
+ .getElementById("second-ad")
+ .getBoundingClientRect();
+ // The 100 is just to guarantee we've scrolled past the element.
+ content.scrollTo(0, el.top + el.height + 100);
+ });
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "6",
+ ads_visible: "4",
+ ads_hidden: "0",
+ },
+ ]);
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_impressions_without_ads() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await promiseAdImpressionReceived();
+
+ assertAdImpressionEvents([
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ]);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_categorization_timing.js b/browser/components/search/test/browser/browser_search_telemetry_categorization_timing.js
new file mode 100644
index 0000000000..69b43ae19a
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_categorization_timing.js
@@ -0,0 +1,107 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Checks that telemetry on the runtime performance of categorizing the SERP
+ * works as normal.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_tab_contains_measurement() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd_components_text.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ await Services.fog.testFlushAllChildren();
+ Assert.ok(
+ Glean.serp.adImpression.testGetValue().length,
+ "Should have received ad impressions."
+ );
+
+ let durations = Glean.serp.categorizationDuration.testGetValue();
+ Assert.ok(durations.sum > 0, "Sum should be more than 0.");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// If the user opened a SERP and closed it quickly or navigated away from it
+// and no ad impressions were recorded, we shouldn't record a measurement.
+add_task(async function test_before_ad_impressions_recorded() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd_components_text.html")
+ );
+ BrowserTestUtils.removeTab(tab);
+
+ Assert.ok(
+ !Glean.serp.adImpression.testGetValue(),
+ "Should not have an ad impression."
+ );
+
+ await Services.fog.testFlushAllChildren();
+ let durations = Glean.serp.categorizationDuration.testGetValue();
+ Assert.equal(durations, undefined, "Should not have received any values.");
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_content.js b/browser/components/search/test/browser/browser_search_telemetry_content.js
new file mode 100644
index 0000000000..b17604badd
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_content.js
@@ -0,0 +1,204 @@
+"use strict";
+
+const BASE_PROBE_NAME = "browser.engagement.navigation.";
+const SCALAR_CONTEXT_MENU = BASE_PROBE_NAME + "contextmenu";
+const SCALAR_ABOUT_NEWTAB = BASE_PROBE_NAME + "about_newtab";
+
+add_setup(async function () {
+ // Create two new search engines. Mark one as the default engine, so
+ // the test don't crash. We need to engines for this test as the searchbar
+ // in content doesn't display the default search engine among the one-off engines.
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ keyword: "mozalias",
+ },
+ { setAsDefault: true }
+ );
+ await SearchTestUtils.installSearchExtension({
+ name: "MozSearch2",
+ keyword: "mozalias2",
+ });
+
+ // Move the second engine at the beginning of the one-off list.
+ let engineOneOff = Services.search.getEngineByName("MozSearch2");
+ await Services.search.moveEngine(engineOneOff, 0);
+
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ // Enable event recording for the events tested here.
+ Services.telemetry.setEventRecordingEnabled("navigation", true);
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.history.clear();
+ Services.telemetry.setEventRecordingEnabled("navigation", false);
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+});
+
+add_task(async function test_context_menu() {
+ // Let's reset the Telemetry data.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let search_hist =
+ TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS");
+
+ // Open a new tab with a page containing some text.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/plain;charset=utf8,test%20search"
+ );
+
+ info("Select all the text in the page.");
+ await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () {
+ return new Promise(resolve => {
+ content.document.addEventListener("selectionchange", () => resolve(), {
+ once: true,
+ });
+ content.document.getSelection().selectAllChildren(content.document.body);
+ });
+ });
+
+ info("Open the context menu.");
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupPromise;
+
+ info("Click on search.");
+ let searchItem = contextMenu.getElementsByAttribute(
+ "id",
+ "context-searchselect"
+ )[0];
+ contextMenu.activateItem(searchItem);
+
+ info("Validate the search metrics.");
+
+ // Telemetry is not updated synchronously here, we must wait for it.
+ await TestUtils.waitForCondition(() => {
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+ return Object.keys(scalars[SCALAR_CONTEXT_MENU] || {}).length == 1;
+ }, "This search must increment one entry in the scalar.");
+
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ SCALAR_CONTEXT_MENU,
+ "search",
+ 1
+ );
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ TelemetryTestUtils.assertKeyedHistogramSum(
+ search_hist,
+ "other-MozSearch.contextmenu",
+ 1
+ );
+
+ // Also check events.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "contextmenu",
+ value: null,
+ extra: { engine: "other-MozSearch" },
+ },
+ ],
+ { category: "navigation", method: "search" }
+ );
+
+ contextMenu.hidePopup();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_about_newtab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ Services.fog.testResetFOG();
+ let search_hist =
+ TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(() => !content.document.hidden);
+ });
+
+ info("Trigger a simple serch, just text + enter.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await typeInSearchField(
+ tab.linkedBrowser,
+ "test query",
+ "newtab-search-text"
+ );
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser);
+ await p;
+
+ // Check if the scalars contain the expected values.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ SCALAR_ABOUT_NEWTAB,
+ "search_enter",
+ 1
+ );
+ Assert.equal(
+ Object.keys(scalars[SCALAR_ABOUT_NEWTAB]).length,
+ 1,
+ "This search must only increment one entry in the scalar."
+ );
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ TelemetryTestUtils.assertKeyedHistogramSum(
+ search_hist,
+ "other-MozSearch.newtab",
+ 1
+ );
+
+ // Also check events.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "about_newtab",
+ value: "enter",
+ extra: { engine: "other-MozSearch" },
+ },
+ ],
+ { category: "navigation", method: "search" }
+ );
+
+ // Also also check Glean events.
+ const record = Glean.newtabSearch.issued.testGetValue();
+ Assert.ok(!!record, "Must have recorded a search issuance");
+ Assert.equal(record.length, 1, "One search, one event");
+ Assert.deepEqual(
+ {
+ search_access_point: "about_newtab",
+ telemetry_id: "other-MozSearch",
+ },
+ record[0].extra,
+ "Must have recorded the expected information."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_cached.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached.js
new file mode 100644
index 0000000000..6def0ef3ad
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load SERPs and click on cacheable links.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ included: {
+ parent: {
+ selector: ".moz-carousel",
+ },
+ children: [
+ {
+ selector: ".moz-carousel-card",
+ countChildren: true,
+ },
+ ],
+ related: {
+ selector: "button",
+ },
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ included: {
+ parent: {
+ selector: ".moz_ad",
+ },
+ children: [
+ {
+ selector: ".multi-col",
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ related: {
+ selector: "button",
+ },
+ },
+ excluded: {
+ parent: {
+ selector: ".rhs",
+ },
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ included: {
+ parent: {
+ selector: "form",
+ },
+ children: [
+ {
+ selector: "input",
+ },
+ ],
+ related: {
+ selector: "div",
+ },
+ },
+ topDown: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+async function promiseImpressionReceived() {
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+}
+
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_click_cached_page() {
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let cacheableUrl =
+ "https://example.com/browser/browser/components/search/test/browser/cacheable.html";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ cacheableUrl
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#non_ads_link",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ gBrowser.goBack();
+ await waitForPageWithAdImpressions();
+
+ pageLoadPromise = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ cacheableUrl
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#non_ads_link",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_cached_serp.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached_serp.js
new file mode 100644
index 0000000000..26ebea5a56
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached_serp.js
@@ -0,0 +1,239 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests check when a SERP retrieves data from the BFCache as SERPs
+ * typically set their response headers with Cache-Control as private.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [/^https:\/\/example.com/],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ included: {
+ parent: {
+ selector: "form",
+ },
+ children: [
+ {
+ selector: "input",
+ },
+ ],
+ related: {
+ selector: "div",
+ },
+ },
+ topDown: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, term) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=${term}`;
+}
+
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+async function goBack(tab, callback = async () => {}) {
+ info("Go back.");
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "pageshow"
+ );
+ tab.linkedBrowser.goBack();
+ await pageShowPromise;
+ await callback();
+}
+
+async function goForward(tab, callback = async () => {}) {
+ info("Go forward.");
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "pageshow"
+ );
+ tab.linkedBrowser.goForward();
+ await pageShowPromise;
+ await callback();
+}
+
+// This test loads a cached SERP and checks returning to it and interacting
+// with elements on the page don't count the events more than once.
+// This is a proxy for ensuring we remove event listeners.
+add_task(async function test_cached_serp() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox.html");
+ info("Load search page.");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ for (let index = 0; index < 3; ++index) {
+ info("Load non-search page.");
+ let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, true);
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ "https://www.example.com"
+ );
+ await loadPromise;
+ await goBack(tab, async () => {
+ await waitForPageWithAdImpressions();
+ });
+ }
+
+ info("Click on searchbox.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "input",
+ {},
+ tab.linkedBrowser
+ );
+
+ await Services.fog.testFlushAllChildren();
+ let engagements = Glean.serp.engagement.testGetValue() ?? [];
+ Assert.equal(
+ engagements.length,
+ 1,
+ "There should be 1 engagement event recorded."
+ );
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_back_and_forward_serp_to_serp() {
+ await SpecialPowers.pushPrefEnv({
+ // This has to be disabled or else using back and forward in the test won't
+ // trigger responses in the network listener in SearchSERPTelemetry. The
+ // page will still load from a BFCache.
+ set: [["fission.bfcacheInParent", false]],
+ });
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_searchbox.html");
+ info("Load search page.");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false);
+ info("Click on a suggested search term.");
+ BrowserTestUtils.synthesizeMouseAtCenter("#suggest", {}, tab.linkedBrowser);
+ await loadPromise;
+ await waitForPageWithAdImpressions();
+
+ for (let index = 0; index < 3; ++index) {
+ info("Return to first search page.");
+ await goBack(tab, async () => {
+ await waitForPageWithAdImpressions();
+ });
+ info("Return to second search page.");
+ await goForward(tab, async () => {
+ await waitForPageWithAdImpressions();
+ });
+ }
+
+ await Services.fog.testFlushAllChildren();
+ let engagements = Glean.serp.engagement.testGetValue() ?? [];
+ let abandonments = Glean.serp.abandonment.testGetValue() ?? [];
+ Assert.equal(engagements.length, 1, "There should be 1 engagement.");
+ Assert.equal(abandonments.length, 6, "There should be 6 abandonments.");
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_back_and_forward_content_to_serp_to_serp() {
+ await SpecialPowers.pushPrefEnv({
+ // This has to be disabled or else using back and forward in the test won't
+ // trigger responses in the network listener in SearchSERPTelemetry. The
+ // page will still load from a BFCache.
+ set: [["fission.bfcacheInParent", false]],
+ });
+ resetTelemetry();
+
+ info("Load non-search page.");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://www.example.com/"
+ );
+
+ info("Load search page.");
+ let url = getSERPUrl("searchTelemetryAd_searchbox.html");
+ let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, true);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ await loadPromise;
+ await waitForPageWithAdImpressions();
+
+ loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false);
+ info("Click on a suggested search term.");
+ BrowserTestUtils.synthesizeMouseAtCenter("#suggest", {}, tab.linkedBrowser);
+ await loadPromise;
+ await waitForPageWithAdImpressions();
+
+ info("Return to first search page.");
+ await goBack(tab, async () => {
+ await waitForPageWithAdImpressions();
+ });
+
+ info("Return to non-search page.");
+ await goBack(tab);
+
+ info("Return to first search page.");
+ await goForward(tab, async () => {
+ await waitForPageWithAdImpressions();
+ });
+
+ info("Return to second search page.");
+ await goForward(tab, async () => {
+ await waitForPageWithAdImpressions();
+ });
+
+ await Services.fog.testFlushAllChildren();
+ let engagements = Glean.serp.engagement.testGetValue() ?? [];
+ let abandonments = Glean.serp.abandonment.testGetValue() ?? [];
+ Assert.equal(engagements.length, 1, "There should be 1 engagement.");
+ Assert.equal(abandonments.length, 3, "There should be 3 abandonments.");
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_content.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_content.js
new file mode 100644
index 0000000000..e42fe4c293
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_content.js
@@ -0,0 +1,486 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load a SERP that has multiple ways of refining a search term
+ * within content, or moving it into another search engine. It is also common
+ * for providers to remove tracking params.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_searchbox_with_content.html/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_searchbox_with_content_redirect.html/,
+ ],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ shoppingTab: {
+ selector: "nav a",
+ regexp: "&page=shopping",
+ inspectRegexpInSERP: true,
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ included: {
+ parent: {
+ selector: "form",
+ },
+ children: [
+ {
+ selector: "input",
+ },
+ ],
+ related: {
+ selector: "div",
+ },
+ },
+ topDown: true,
+ nonAd: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ included: {
+ parent: {
+ selector: ".refined-search-buttons",
+ },
+ children: [
+ {
+ selector: "a",
+ },
+ ],
+ },
+ topDown: true,
+ nonAd: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+async function promiseImpressionReceived() {
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+}
+
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+// "Tabs" are considered to be links the navigation of a SERP. Their hrefs
+// may look similar to a search page, including related searches.
+add_task(async function test_click_tab() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#images",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Ensure that shopping links on a page with many non-ad link regular
+// expressions doesn't get confused for a non-ads link.
+add_task(async function test_click_shopping() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#shopping",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "true",
+ shopping_tab_displayed: "true",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_click_related_search_in_new_tab() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#related-new-tab",
+ {},
+ tab.linkedBrowser
+ );
+ let tab2 = await tabPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+// We consider regular expressions in nonAdsLinkRegexps and searchPageRegexp
+// as valid non ads links when recording an engagement event.
+add_task(async function test_click_redirect_search_in_newtab() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#related-redirect",
+ {},
+ tab.linkedBrowser
+ );
+ let tab2 = await tabPromise;
+
+ await waitForPageWithAdImpressions();
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+// Ensure if a user does a search that uses one of the in-content sources,
+// we clear the cached source value.
+add_task(async function test_content_source_reset() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ // Do a text search to trigger a defined target.
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "form input",
+ {},
+ tab.linkedBrowser
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ await pageLoadPromise;
+
+ // Click on a related search that will load within the same page and should
+ // have an unknown target.
+ await waitForPageWithAdImpressions();
+ pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#related-in-page",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression.testGetValue()?.length == 3;
+ }, "Should have three impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_incontent_search",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This test also deliberately includes an anchor with a reserved character in
+// the href that gets parsed on page load. This is because when the URL is
+// requested and observed in the network process, it is converted into a
+// percent encoded string, so we want to ensure we're categorizing the
+// component properly. This can happen with refinement buttons.
+add_task(async function test_click_refinement_button() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_searchbox_with_content.html?s=test%27s";
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ targetUrl
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#refined-search-button",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_multiple_tabs.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_multiple_tabs.js
new file mode 100644
index 0000000000..8010434b88
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_multiple_tabs.js
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This test ensures that recorded telemetry is consistent even with multiple
+ * tabs opened and closed.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_nonAdsLink_redirect/,
+ ],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ included: {
+ parent: {
+ selector: "form",
+ },
+ children: [
+ {
+ // This isn't contained in any of the HTML examples but the
+ // presence of the entry ensures that if it is not found during
+ // a topDown examination, the next element in the array is
+ // inspected and found.
+ selector: "textarea",
+ },
+ {
+ selector: "input",
+ },
+ ],
+ related: {
+ selector: "div",
+ },
+ },
+ topDown: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+// Deliberately make the web isolated process count as small as possible
+// so that we don't have to create a ton of tabs to reuse a process.
+const MAX_IPC = 1;
+const TABS_TO_OPEN = 2;
+
+function getSERPUrl(page, term) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=${term}`;
+}
+
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ["dom.ipc.processCount.webIsolated", MAX_IPC],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+async function do_test(tab, impressionId, switchTab) {
+ if (switchTab) {
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "input",
+ {},
+ tab.linkedBrowser
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#images",
+ {},
+ tab.linkedBrowser
+ );
+
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser, true);
+
+ await Services.fog.testFlushAllChildren();
+ let engagements = Glean.serp.engagement.testGetValue() ?? [];
+ Assert.equal(engagements.length, 2, "Should have two events recorded.");
+
+ Assert.deepEqual(
+ engagements[0].extra,
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ impression_id: impressionId,
+ },
+ "Search box engagement event should match."
+ );
+ Assert.deepEqual(
+ engagements[1].extra,
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ impression_id: impressionId,
+ },
+ "Non ads page engagement event should match."
+ );
+ resetTelemetry();
+}
+
+// This test deliberately opens a lot of tabs to ensure SERPs share the
+// same process. It interacts with the page to ensure the engagement
+// has the correct recording, especially the impression id which can be out of
+// sync if data in the child process isn't cached properly.
+add_task(async function test_multiple_tabs_forward() {
+ resetTelemetry();
+
+ let tabs = [];
+ let pid;
+
+ // Open multiple tabs.
+ for (let index = 0; index < TABS_TO_OPEN; ++index) {
+ let url = getSERPUrl(
+ "searchTelemetryAd_searchbox_with_content.html",
+ `hello+world+${index}`
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+ tabs.push(tab);
+ let currentPid = E10SUtils.getBrowserPids(tab.linkedBrowser).at(0);
+ if (pid == null) {
+ pid = currentPid;
+ } else {
+ Assert.ok(pid == currentPid, "The process ID should be the same.");
+ }
+ }
+
+ // Extract the impression IDs.
+ await Services.fog.testFlushAllChildren();
+ let recordedImpressions = Glean.serp.impression.testGetValue() ?? [];
+ let impressionIds = recordedImpressions.map(
+ impression => impression.extra.impression_id
+ );
+
+ // Reset telemetry because we're not concerned about inspecting every
+ // impression event.
+ resetTelemetry();
+
+ for (let index = 0; index < TABS_TO_OPEN; ++index) {
+ let tab = tabs[index];
+ let impressionId = impressionIds[index];
+ await do_test(tab, impressionId, true);
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function test_multiple_tabs_backward() {
+ resetTelemetry();
+
+ let tabs = [];
+ let pid;
+
+ for (let index = 0; index < TABS_TO_OPEN; ++index) {
+ let url = getSERPUrl(
+ "searchTelemetryAd_searchbox_with_content.html",
+ `hello+world+${index}`
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+ tabs.push(tab);
+ let currentPid = E10SUtils.getBrowserPids(tab.linkedBrowser).at(0);
+ if (pid == null) {
+ pid = currentPid;
+ } else {
+ Assert.ok(pid == currentPid, "The process ID should be the same.");
+ }
+ }
+
+ // Extract the impression IDs.
+ await Services.fog.testFlushAllChildren();
+ let recordedImpressions = Glean.serp.impression.testGetValue() ?? [];
+ let impressionIds = recordedImpressions.map(
+ impression => impression.extra.impression_id
+ );
+
+ // Reset telemetry because we're not concerned about inspecting every
+ // impression event.
+ resetTelemetry();
+
+ for (let index = TABS_TO_OPEN - 1; index >= 0; --index) {
+ let tab = tabs[index];
+ let impressionId = impressionIds[index];
+ await do_test(tab, impressionId, false);
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_non_ad.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_non_ad.js
new file mode 100644
index 0000000000..ebec383e92
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_non_ad.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load SERPs and click on links that are non ads. Non ads can have
+ * slightly different behavior from ads.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+async function promiseImpressionReceived() {
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+}
+
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+// If an anchor is a non_ads_link and it doesn't match a non-ads regular
+// expression, it should still be categorize it as a non ad.
+add_task(async function test_click_non_ads_link() {
+ await waitForIdle();
+
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ // Click a non ad.
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non_ads_link",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Reset state for other tests.
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+});
+
+// Click on an non-ad element while no ads are present.
+add_task(async function test_click_non_ad_with_no_ads() {
+ await waitForIdle();
+
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_searchbox.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ "https://example.com/hello_world"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non_ads_link",
+ {},
+ tab.linkedBrowser
+ );
+ await browserLoadedPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Reset state for other tests.
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_redirect.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_redirect.js
new file mode 100644
index 0000000000..30c18b8059
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_redirect.js
@@ -0,0 +1,346 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load SERPs and click on both ad and non-ad links that can be
+ * redirected.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_nonAdsLink_redirect.html/,
+ ],
+ extraAdServersRegexps: [
+ /^https:\/\/example\.com\/ad/,
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/redirect_ad/,
+ ],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+async function promiseImpressionReceived() {
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+}
+
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_click_non_ads_link_redirected() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl = "https://example.com/hello_world";
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ targetUrl
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non_ads_link_redirected",
+ {},
+ tab.linkedBrowser
+ );
+
+ await browserLoadedPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// If a provider does a re-direct and we open it in a new tab, we should
+// record the click and have the correct number of engagements.
+add_task(async function test_click_non_ads_link_redirected_new_tab() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let redirectUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_nonAdsLink_redirect.html";
+ let targetUrl = "https://example.com/hello_world";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [redirectUrl], urls => {
+ content.document
+ .getElementById(["non_ads_link"])
+ .addEventListener("click", e => {
+ e.preventDefault();
+ content.window.open([urls], "_blank");
+ });
+ content.document.getElementById("non_ads_link").click();
+ });
+ let tab2 = await tabPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+// Some providers load a URL of a non ad within a subframe before loading the
+// target website in the top level frame.
+add_task(async function test_click_non_ads_link_redirect_non_top_level() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl = "https://example.com/hello_world";
+
+ let browserPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ targetUrl
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non_ads_link_redirected_no_top_level",
+ {},
+ tab.linkedBrowser
+ );
+
+ await browserPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_multiple_redirects_non_ad_link() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl = "https://example.com/hello_world";
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ targetUrl
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non_ads_link_multiple_redirects",
+ {},
+ tab.linkedBrowser
+ );
+
+ await browserLoadedPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_click_ad_link_redirected() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl = "https://example.com/hello_world";
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ targetUrl
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad_link_redirect",
+ {},
+ tab.linkedBrowser
+ );
+
+ await browserLoadedPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_click_ad_link_redirected_new_tab() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl = "https://example.com/hello_world";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad_link_redirect",
+ { button: 1 },
+ tab.linkedBrowser
+ );
+ let tab2 = await tabPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_target.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_target.js
new file mode 100644
index 0000000000..d29169b776
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_target.js
@@ -0,0 +1,433 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load SERPs and click on links that can either be ads or non ads
+ * and verifies that the engagement events and the target associated with them
+ * are correct.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_nonAdsLink_redirect.html/,
+ ],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ included: {
+ parent: {
+ selector: ".moz-carousel",
+ },
+ children: [
+ {
+ selector: ".moz-carousel-card",
+ countChildren: true,
+ },
+ ],
+ related: {
+ selector: "button",
+ },
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ included: {
+ parent: {
+ selector: ".moz_ad",
+ },
+ children: [
+ {
+ selector: ".multi-col",
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ related: {
+ selector: "button",
+ },
+ },
+ excluded: {
+ parent: {
+ selector: ".rhs",
+ },
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ included: {
+ parent: {
+ selector: "form",
+ },
+ children: [
+ {
+ // This isn't contained in any of the HTML examples but the
+ // presence of the entry ensures that if it is not found during
+ // a topDown examination, the next element in the array is
+ // inspected and found.
+ selector: "textarea",
+ },
+ {
+ selector: "input",
+ },
+ ],
+ related: {
+ selector: "div",
+ },
+ },
+ topDown: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+// This is used to check if not providing an nonAdsLinkRegexp can still
+// reliably categorize non_ads_links.
+const TEST_PROVIDER_INFO_NO_NON_ADS_REGEXP = [
+ {
+ ...TEST_PROVIDER_INFO[0],
+ nonAdsLinkRegexps: [],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+async function promiseImpressionReceived() {
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+}
+
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+// This test ensures clicking a non-first link in a component registers the
+// proper component. This is because the first link of a component does the
+// heavy lifting in finding the parent and best categorization of the
+// component. Subsequent anchors that have the same parent get grouped into it.
+// Additionally, this test deliberately has ads with different paths so that
+// there are no collisions in hrefs.
+add_task(async function test_click_second_ad_in_component() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#deep_ad_sitelink",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// If a provider appends query parameters to a link after the page has been
+// parsed, we should still be able to record the click.
+add_task(async function test_click_ads_link_modified() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ let target = content.document.getElementById("deep_ad_sitelink");
+ let href = target.getAttribute("href");
+ target.setAttribute("href", href + "?foo=bar");
+ content.document.getElementById("deep_ad_sitelink").click();
+ });
+ await browserLoadedPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Search box is a special case which has to be tracked in the child process.
+add_task(async function test_click_and_submit_incontent_searchbox() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ // Click on the searchbox.
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser, url);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "form input",
+ {},
+ tab.linkedBrowser
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_incontent_search",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Click an auto-suggested term. The element that is clicked is related
+// to the searchbox but not in search-telemetry-v2 because it can be too
+// difficult to determine ahead of time since the elements are generated
+// dynamically. So instead it should listen to an element higher in the DOM.
+add_task(async function test_click_autosuggest() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ // Click an autosuggested term.
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser, url);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#suggest",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_incontent_search",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Carousel related buttons expand content.
+add_task(async function test_click_carousel_expand() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_carousel.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ // Click a button that is expected to expand.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.querySelector("button").click();
+ });
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.EXPANDED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This test clicks a link that has apostrophes in the both the path and list
+// of query parameters, and uses search telemetry with no nonAdsRegexps defined,
+// which will force us to cache every non ads link in a map and pass it back to
+// the parent.
+// If this test fails, it means we're doing the conversion wrong, because when
+// we observe the clicked URL in the parent process, it should look exactly the
+// same as how it was saved in the hrefToComponent map.
+add_task(async function test_click_link_with_special_characters_in_path() {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(
+ TEST_PROVIDER_INFO_NO_NON_ADS_REGEXP
+ );
+ await waitForIdle();
+
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_components_text.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ "https://example.com/path'?hello_world&foo=bar%27s"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non_ads_link_with_special_characters_in_path",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Reset state for other tests.
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_searchbar.js b/browser/components/search/test/browser/browser_search_telemetry_searchbar.js
new file mode 100644
index 0000000000..15f512452a
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_searchbar.js
@@ -0,0 +1,440 @@
+"use strict";
+
+const SCALAR_SEARCHBAR = "browser.engagement.navigation.searchbar";
+
+ChromeUtils.defineESModuleGetters(this, {
+ UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs",
+});
+
+let suggestionEngine;
+
+function checkHistogramResults(resultIndexes, expected, histogram) {
+ for (let [i, val] of Object.entries(resultIndexes.values)) {
+ if (i == expected) {
+ Assert.equal(
+ val,
+ 1,
+ `expected counts should match for ${histogram} index ${i}`
+ );
+ } else {
+ Assert.equal(
+ !!val,
+ false,
+ `unexpected counts should be zero for ${histogram} index ${i}`
+ );
+ }
+ }
+}
+
+/**
+ * Click one of the entries in the search suggestion popup.
+ *
+ * @param {string} entryName
+ * The name of the elemet to click on.
+ * @param {object} [clickOptions]
+ * The options to use for the click.
+ */
+function clickSearchbarSuggestion(entryName, clickOptions = {}) {
+ let richlistbox = BrowserSearch.searchBar.textbox.popup.richlistbox;
+ let richlistitem = Array.prototype.find.call(
+ richlistbox.children,
+ item => item.getAttribute("ac-value") == entryName
+ );
+
+ // Make sure the suggestion is visible and simulate the click.
+ richlistbox.ensureElementIsVisible(richlistitem);
+ EventUtils.synthesizeMouseAtCenter(richlistitem, clickOptions);
+}
+
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+ const url = getRootDirectory(gTestPath) + "telemetrySearchSuggestions.xml";
+ suggestionEngine = await SearchTestUtils.promiseNewSearchEngine({ url });
+
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+
+ // Create two new search engines. Mark one as the default engine, so
+ // the test don't crash. We need to engines for this test as the searchbar
+ // doesn't display the default search engine among the one-off engines.
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ keyword: "mozalias",
+ },
+ { setAsDefault: true }
+ );
+ await SearchTestUtils.installSearchExtension({
+ name: "MozSearch2",
+ keyword: "mozalias2",
+ });
+
+ // Move the second engine at the beginning of the one-off list.
+ let engineOneOff = Services.search.getEngineByName("MozSearch2");
+ await Services.search.moveEngine(engineOneOff, 0);
+
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ // Enable event recording for the events tested here.
+ Services.telemetry.setEventRecordingEnabled("navigation", true);
+
+ registerCleanupFunction(async function () {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ Services.telemetry.setEventRecordingEnabled("navigation", false);
+ });
+});
+
+add_task(async function test_plainQuery() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let resultMethodHist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+ let search_hist =
+ TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Simulate entering a simple search.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await searchInSearchbar("simple query");
+ EventUtils.synthesizeKey("KEY_Enter");
+ await p;
+
+ // Check if the scalars contain the expected values.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ SCALAR_SEARCHBAR,
+ "search_enter",
+ 1
+ );
+ Assert.equal(
+ Object.keys(scalars[SCALAR_SEARCHBAR]).length,
+ 1,
+ "This search must only increment one entry in the scalar."
+ );
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ TelemetryTestUtils.assertKeyedHistogramSum(
+ search_hist,
+ "other-MozSearch.searchbar",
+ 1
+ );
+
+ // Also check events.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "searchbar",
+ value: "enter",
+ extra: { engine: "other-MozSearch" },
+ },
+ ],
+ { category: "navigation", method: "search" }
+ );
+
+ // Check the histograms as well.
+ let resultMethods = resultMethodHist.snapshot();
+ checkHistogramResults(
+ resultMethods,
+ UrlbarTestUtils.SELECTED_RESULT_METHODS.enter,
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Performs a search using the first result, a one-off button, and the Return
+// (Enter) key.
+add_task(async function test_oneOff_enter() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let resultMethodHist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+ let search_hist =
+ TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Perform a one-off search using the first engine.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await searchInSearchbar("query");
+
+ info("Pressing Alt+Down to highlight the first one off engine.");
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ EventUtils.synthesizeKey("KEY_Enter");
+ await p;
+
+ // Check if the scalars contain the expected values.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ SCALAR_SEARCHBAR,
+ "search_oneoff",
+ 1
+ );
+ Assert.equal(
+ Object.keys(scalars[SCALAR_SEARCHBAR]).length,
+ 1,
+ "This search must only increment one entry in the scalar."
+ );
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ TelemetryTestUtils.assertKeyedHistogramSum(
+ search_hist,
+ "other-MozSearch2.searchbar",
+ 1
+ );
+
+ // Also check events.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "searchbar",
+ value: "oneoff",
+ extra: { engine: "other-MozSearch2" },
+ },
+ ],
+ { category: "navigation", method: "search" }
+ );
+
+ // Check the histograms as well.
+ let resultMethods = resultMethodHist.snapshot();
+ checkHistogramResults(
+ resultMethods,
+ UrlbarTestUtils.SELECTED_RESULT_METHODS.enter,
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Performs a search using the second result, a one-off button, and the Return
+// (Enter) key. This only tests the FX_SEARCHBAR_SELECTED_RESULT_METHOD
+// histogram since test_oneOff_enter covers everything else.
+add_task(async function test_oneOff_enterSelection() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ let resultMethodHist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ let previousEngine = await Services.search.getDefault();
+ await Services.search.setDefault(
+ suggestionEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Type a query. Suggestions should be generated by the test engine.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await searchInSearchbar("query");
+
+ info(
+ "Select the second result, press Alt+Down to take us to the first one-off engine."
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ EventUtils.synthesizeKey("KEY_Enter");
+ await p;
+
+ let resultMethods = resultMethodHist.snapshot();
+ checkHistogramResults(
+ resultMethods,
+ UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection,
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ await Services.search.setDefault(
+ previousEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Performs a search using a click on a one-off button. This only tests the
+// FX_SEARCHBAR_SELECTED_RESULT_METHOD histogram since test_oneOff_enter covers
+// everything else.
+add_task(async function test_oneOff_click() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ let resultMethodHist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Type a query.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ let popup = await searchInSearchbar("query");
+ info("Click the first one-off button.");
+ popup.oneOffButtons.getSelectableButtons(false)[0].click();
+ await p;
+
+ let resultMethods = resultMethodHist.snapshot();
+ checkHistogramResults(
+ resultMethods,
+ UrlbarTestUtils.SELECTED_RESULT_METHODS.click,
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+async function checkSuggestionClick(clickOptions, waitForActionFn) {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+ let resultMethodHist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+ let search_hist =
+ TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS");
+
+ let previousEngine = await Services.search.getDefault();
+ await Services.search.setDefault(
+ suggestionEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Perform a one-off search using the first engine.");
+ let p = waitForActionFn(tab);
+ await searchInSearchbar("query");
+ info("Clicking the searchbar suggestion.");
+ clickSearchbarSuggestion("queryfoo", clickOptions);
+ await p;
+
+ // Check if the scalars contain the expected values.
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ SCALAR_SEARCHBAR,
+ "search_suggestion",
+ 1
+ );
+ Assert.equal(
+ Object.keys(scalars[SCALAR_SEARCHBAR]).length,
+ 1,
+ "This search must only increment one entry in the scalar."
+ );
+
+ // Make sure SEARCH_COUNTS contains identical values.
+ let searchEngineId = "other-" + suggestionEngine.name;
+ TelemetryTestUtils.assertKeyedHistogramSum(
+ search_hist,
+ searchEngineId + ".searchbar",
+ 1
+ );
+
+ // Also check events.
+ TelemetryTestUtils.assertEvents(
+ [
+ {
+ object: "searchbar",
+ value: "suggestion",
+ extra: { engine: searchEngineId },
+ },
+ ],
+ { category: "navigation", method: "search" }
+ );
+
+ // Check the histograms as well.
+ let resultMethods = resultMethodHist.snapshot();
+ checkHistogramResults(
+ resultMethods,
+ UrlbarTestUtils.SELECTED_RESULT_METHODS.click,
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ await Services.search.setDefault(
+ previousEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ BrowserTestUtils.removeTab(tab);
+}
+
+// Clicks the first suggestion offered by the test search engine.
+add_task(async function test_suggestion_click() {
+ await checkSuggestionClick({}, tab => {
+ return BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ });
+});
+
+add_task(async function test_suggestion_middle_click() {
+ let openedTab;
+ await checkSuggestionClick({ button: 1 }, () => {
+ return BrowserTestUtils.waitForNewTab(gBrowser, "http://example.com/").then(
+ tab => (openedTab = tab)
+ );
+ });
+ BrowserTestUtils.removeTab(openedTab);
+});
+
+// Selects and presses the Return (Enter) key on the first suggestion offered by
+// the test search engine. This only tests the
+// FX_SEARCHBAR_SELECTED_RESULT_METHOD histogram since test_suggestion_click
+// covers everything else.
+add_task(async function test_suggestion_enterSelection() {
+ // Let's reset the counts.
+ Services.telemetry.clearScalars();
+ let resultMethodHist = TelemetryTestUtils.getAndClearHistogram(
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ let previousEngine = await Services.search.getDefault();
+ await Services.search.setDefault(
+ suggestionEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ info("Type a query. Suggestions should be generated by the test engine.");
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await searchInSearchbar("query");
+ info("Select the second result and press Return.");
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ EventUtils.synthesizeKey("KEY_Enter");
+ await p;
+
+ let resultMethods = resultMethodHist.snapshot();
+ checkHistogramResults(
+ resultMethods,
+ UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection,
+ "FX_SEARCHBAR_SELECTED_RESULT_METHOD"
+ );
+
+ await Services.search.setDefault(
+ previousEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_shopping.js b/browser/components/search/test/browser/browser_search_telemetry_shopping.js
new file mode 100644
index 0000000000..2623562a8f
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_shopping.js
@@ -0,0 +1,149 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Check the existence of a shopping tab and navigation to a shopping page.
+ * Most existing tests don't include shopping tabs, so this explicitly loads a
+ * page with a shopping tab and clicks on it.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+// The setup for each test is the same, the only differences are the various
+// permutations of the search tests.
+const BASE_TEST_PROVIDER = {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ extraAdServersRegexps: [/^https:\/\/example\.org\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+};
+
+const TEST_PROVIDER_INFO_1 = [
+ {
+ ...BASE_TEST_PROVIDER,
+ shoppingTab: {
+ selector: "nav a",
+ regexp: "&page=shopping&",
+ inspectRegexpInSERP: true,
+ },
+ },
+];
+
+const TEST_PROVIDER_INFO_2 = [
+ {
+ ...BASE_TEST_PROVIDER,
+ shoppingTab: {
+ selector: "nav a#shopping",
+ regexp: "&page=shopping&",
+ inspectRegexpInSERP: false,
+ },
+ },
+];
+
+function getSERPUrl(page) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test&abc=ff`;
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO_1);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+async function loadSerpAndClickShoppingTab(page) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(page)
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ },
+ ]);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ BrowserTestUtils.synthesizeMouseAtCenter("#shopping", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function test_inspect_shopping_tab_regexp_on_serp() {
+ resetTelemetry();
+ await loadSerpAndClickShoppingTab("searchTelemetryAd_shopping.html");
+});
+
+add_task(async function test_no_inspect_shopping_tab_regexp_on_serp() {
+ resetTelemetry();
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO_2);
+ await waitForIdle();
+ await loadSerpAndClickShoppingTab("searchTelemetryAd_shopping.html");
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources.js b/browser/components/search/test/browser/browser_search_telemetry_sources.js
new file mode 100644
index 0000000000..173bc09694
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_sources.js
@@ -0,0 +1,497 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Main tests for SearchSERPTelemetry - general engine visiting and link clicking.
+ *
+ * NOTE: As this test file is already fairly long-running, adding to this file
+ * will likely cause timeout errors with test-verify jobs on Treeherder.
+ * Therefore, please do not add further tasks to this file.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getPageUrl(useAdPage = false) {
+ let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html";
+ return `https://example.org/browser/browser/components/search/test/browser/${page}`;
+}
+
+/**
+ * Returns the index of the first search suggestion in the urlbar results.
+ *
+ * @returns {number} An index, or -1 if there are no search suggestions.
+ */
+async function getFirstSuggestionIndex() {
+ const matchCount = UrlbarTestUtils.getResultCount(window);
+ for (let i = 0; i < matchCount; i++) {
+ let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ if (
+ result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
+ result.searchParams.suggestion
+ ) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+SearchTestUtils.init(this);
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.suggest.searches", true],
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ true,
+ ],
+ // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory.
+ ["browser.urlbar.maxHistoricalSearchSuggestions", 0],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ Services.prefs.setBoolPref("browser.search.log", true);
+
+ await SearchTestUtils.installSearchExtension(
+ {
+ search_url: getPageUrl(true),
+ search_url_get_params: "s={searchTerms}&abc=ff",
+ suggest_url:
+ "https://example.org/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs",
+ suggest_url_get_params: "query={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+
+ await gCUITestUtils.addSearchBar();
+
+ registerCleanupFunction(async () => {
+ gCUITestUtils.removeSearchBar();
+ Services.prefs.clearUserPref("browser.search.log");
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+async function track_ad_click(
+ expectedHistogramSource,
+ expectedScalarSource,
+ searchAdsFn,
+ cleanupFn
+) {
+ searchCounts.clear();
+ Services.telemetry.clearScalars();
+
+ let expectedContentScalarKey = "example:tagged:ff";
+ let expectedScalarKey = "example:tagged";
+ let expectedHistogramSAPSourceKey = `other-Example.${expectedHistogramSource}`;
+ let expectedContentScalar = `browser.search.content.${expectedScalarSource}`;
+ let expectedWithAdsScalar = `browser.search.withads.${expectedScalarSource}`;
+ let expectedAdClicksScalar = `browser.search.adclicks.${expectedScalarSource}`;
+
+ let tab = await searchAdsFn();
+
+ await assertSearchSourcesTelemetry(
+ {
+ [expectedHistogramSAPSourceKey]: 1,
+ },
+ {
+ [expectedContentScalar]: { [expectedContentScalarKey]: 1 },
+ [expectedWithAdsScalar]: { [expectedScalarKey]: 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: expectedScalarSource,
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+ // Ad impression data is needed to categorize ads on the page in order to
+ // register ad click events before a click occurs. We don't assert their
+ // precise values here because other tests cover that the component
+ // categorizations are valid.
+ await promiseAdImpressionReceived();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+ await promiseWaitForAdLinkCheck();
+
+ await assertSearchSourcesTelemetry(
+ {
+ [expectedHistogramSAPSourceKey]: 1,
+ },
+ {
+ [expectedContentScalar]: { [expectedContentScalarKey]: 1 },
+ [expectedWithAdsScalar]: { [expectedScalarKey]: 1 },
+ [expectedAdClicksScalar]: { [expectedScalarKey]: 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: expectedScalarSource,
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ await cleanupFn();
+
+ Services.fog.testResetFOG();
+}
+
+add_task(async function test_source_urlbar() {
+ let tab;
+ await track_ad_click(
+ "urlbar",
+ "urlbar",
+ async () => {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "searchSuggestion",
+ });
+ let idx = await getFirstSuggestionIndex();
+ Assert.greaterOrEqual(idx, 0, "there should be a first suggestion");
+ let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ while (idx--) {
+ EventUtils.sendKey("down");
+ }
+ EventUtils.sendKey("return");
+ await loadPromise;
+ return tab;
+ },
+ async () => {
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+add_task(async function test_source_urlbar_handoff() {
+ let tab;
+ await track_ad_click(
+ "urlbar-handoff",
+ "urlbar_handoff",
+ async () => {
+ Services.fog.testResetFOG();
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:newtab");
+ await BrowserTestUtils.browserStopped(tab.linkedBrowser, "about:newtab");
+
+ info("Focus on search input in newtab content");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ".fake-editable",
+ {},
+ tab.linkedBrowser
+ );
+
+ info("Get suggestions");
+ for (const c of "searchSuggestion".split("")) {
+ EventUtils.synthesizeKey(c);
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ await new Promise(r => setTimeout(r, 50));
+ }
+ await TestUtils.waitForCondition(async () => {
+ const index = await getFirstSuggestionIndex();
+ return index >= 0;
+ }, "Wait until suggestions are ready");
+
+ let idx = await getFirstSuggestionIndex();
+ Assert.greaterOrEqual(idx, 0, "there should be a first suggestion");
+ const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ while (idx--) {
+ EventUtils.sendKey("down");
+ }
+ EventUtils.sendKey("return");
+ await onLoaded;
+
+ return tab;
+ },
+ async () => {
+ const issueRecords = Glean.newtabSearch.issued.testGetValue();
+ Assert.ok(!!issueRecords, "Must have recorded a search issuance");
+ Assert.equal(issueRecords.length, 1, "One search, one event");
+ const newtabVisitId = issueRecords[0].extra.newtab_visit_id;
+ Assert.ok(!!newtabVisitId, "Must have a visit id");
+ Assert.deepEqual(
+ {
+ // Yes, this is tautological. But I want to use deepEqual.
+ newtab_visit_id: newtabVisitId,
+ search_access_point: "urlbar_handoff",
+ telemetry_id: "other-Example",
+ },
+ issueRecords[0].extra,
+ "Must have recorded the expected information."
+ );
+ const impRecords = Glean.newtabSearchAd.impression.testGetValue();
+ Assert.equal(impRecords.length, 1, "One impression, one event.");
+ Assert.deepEqual(
+ {
+ newtab_visit_id: newtabVisitId,
+ search_access_point: "urlbar_handoff",
+ telemetry_id: "example",
+ is_tagged: "true",
+ is_follow_on: "false",
+ },
+ impRecords[0].extra,
+ "Must have recorded the expected information."
+ );
+ const clickRecords = Glean.newtabSearchAd.click.testGetValue();
+ Assert.equal(clickRecords.length, 1, "One click, one event.");
+ Assert.deepEqual(
+ {
+ newtab_visit_id: newtabVisitId,
+ search_access_point: "urlbar_handoff",
+ telemetry_id: "example",
+ is_tagged: "true",
+ is_follow_on: "false",
+ },
+ clickRecords[0].extra,
+ "Must have recorded the expected information."
+ );
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+add_task(async function test_source_searchbar() {
+ let tab;
+ await track_ad_click(
+ "searchbar",
+ "searchbar",
+ async () => {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ let sb = BrowserSearch.searchBar;
+ // Write the search query in the searchbar.
+ sb.focus();
+ sb.value = "searchSuggestion";
+ sb.textbox.controller.startSearch("searchSuggestion");
+ // Wait for the popup to show.
+ await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown");
+ // And then for the search to complete.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ sb.textbox.controller.searchStatus >=
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH,
+ "The search in the searchbar must complete."
+ );
+
+ let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ EventUtils.synthesizeKey("KEY_Enter");
+ await loadPromise;
+ return tab;
+ },
+ async () => {
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+async function checkAboutPage(
+ page,
+ expectedHistogramSource,
+ expectedScalarSource
+) {
+ let tab;
+ await track_ad_click(
+ expectedHistogramSource,
+ expectedScalarSource,
+ async () => {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, page);
+ await BrowserTestUtils.browserStopped(tab.linkedBrowser, page);
+
+ // Wait for the full load.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(
+ () => content.wrappedJSObject.gContentSearchController.defaultEngine
+ );
+ });
+
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await typeInSearchField(
+ tab.linkedBrowser,
+ "test query",
+ "newtab-search-text"
+ );
+ await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser);
+ await p;
+ return tab;
+ },
+ async () => {
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+ }
+ );
+}
+
+add_task(async function test_source_about_home() {
+ await checkAboutPage("about:home", "abouthome", "about_home");
+});
+
+add_task(async function test_source_about_newtab() {
+ await checkAboutPage("about:newtab", "newtab", "about_newtab");
+});
+
+add_task(async function test_source_system() {
+ let tab;
+ await track_ad_click(
+ "system",
+ "system",
+ async () => {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // This is not quite the same as calling from the commandline, but close
+ // enough for this test.
+ BrowserSearch.loadSearchFromCommandLine(
+ "searchSuggestion",
+ false,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ gBrowser.selectedBrowser.csp
+ );
+
+ await loadPromise;
+ return tab;
+ },
+ async () => {
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+add_task(async function test_source_webextension_search() {
+ /* global browser */
+ async function background(SEARCH_TERM) {
+ // Search with no tabId
+ browser.search.search({ query: "searchSuggestion", engine: "Example" });
+ }
+
+ let searchExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["search", "tabs"],
+ },
+ background,
+ useAddonManager: "temporary",
+ });
+
+ let tab;
+ await track_ad_click(
+ "webextension",
+ "webextension",
+ async () => {
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+
+ await searchExtension.startup();
+
+ return (tab = await tabPromise);
+ },
+ async () => {
+ await searchExtension.unload();
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+add_task(async function test_source_webextension_query() {
+ async function background(SEARCH_TERM) {
+ // Search with no tabId
+ browser.search.query({
+ text: "searchSuggestion",
+ disposition: "NEW_TAB",
+ });
+ }
+
+ let searchExtension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ permissions: ["search", "tabs"],
+ },
+ background,
+ useAddonManager: "temporary",
+ });
+
+ let tab;
+ await track_ad_click(
+ "webextension",
+ "webextension",
+ async () => {
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+
+ await searchExtension.startup();
+
+ return (tab = await tabPromise);
+ },
+ async () => {
+ await searchExtension.unload();
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources_ads.js b/browser/components/search/test/browser/browser_search_telemetry_sources_ads.js
new file mode 100644
index 0000000000..880497f993
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_sources_ads.js
@@ -0,0 +1,841 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Main tests for SearchSERPTelemetry - general engine visiting and link clicking.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+// Note: example.org is used for the SERP page, and example.com is used to serve
+// the ads. This is done to simulate different domains like the real servers.
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+ {
+ telemetryId: "example-data-attributes",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_dataAttributes(?:_none|_href)?.html/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["xyz"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+ {
+ telemetryId: "slow-page-load",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/slow_loading_page_with_ads(_on_load_event)?.html/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getPageUrl(useAdPage = false) {
+ let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html";
+ return `https://example.org/browser/browser/components/search/test/browser/${page}`;
+}
+
+function getSERPUrl(page, organic = false) {
+ return `${page}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+function getSERPFollowOnUrl(page) {
+ return page + "?s=test&abc=ff&a=foo";
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.log", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_simple_search_page_visit() {
+ resetTelemetry();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: getSERPUrl(getPageUrl()),
+ },
+ async () => {
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ }
+ );
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+});
+
+add_task(async function test_simple_search_page_visit_telemetry() {
+ resetTelemetry();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ /* URL must not be in the cache */
+ url: getSERPUrl(getPageUrl()) + `&random=${Math.random()}`,
+ },
+ async () => {
+ 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("example" in scalar, "correct telemetry category");
+ Assert.notEqual(scalars[key].example, 0, "bandwidth logged");
+ }
+ );
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+});
+
+add_task(async function test_follow_on_visit() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: getSERPFollowOnUrl(getPageUrl()),
+ },
+ async () => {
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example:tagged:ff": 1,
+ "example:tagged-follow-on:ff": 1,
+ },
+ }
+ );
+ }
+ );
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+});
+
+add_task(async function test_track_ad() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(getPageUrl(true))
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_on_data_attributes() {
+ resetTelemetry();
+
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_dataAttributes.html";
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(url)
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example-data-attributes:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example-data-attributes:tagged": 1,
+ },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example-data-attributes",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_on_data_attributes_and_hrefs() {
+ resetTelemetry();
+
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_dataAttributes_href.html";
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(url)
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example-data-attributes:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example-data-attributes:tagged": 1,
+ },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example-data-attributes",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_no_ad_on_data_attributes_and_hrefs() {
+ resetTelemetry();
+
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_dataAttributes_none.html";
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(url)
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example-data-attributes:tagged:ff": 1,
+ },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example-data-attributes",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_on_DOMContentLoaded() {
+ resetTelemetry();
+
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "slow_loading_page_with_ads.html";
+
+ let observeAdPreviouslyRecorded = TestUtils.consoleMessageObserved(msg => {
+ return (
+ typeof msg.wrappedJSObject.arguments?.[0] == "string" &&
+ msg.wrappedJSObject.arguments[0].includes(
+ "Ad was previously reported for browser with URI"
+ )
+ );
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(url)
+ );
+
+ // Observe ad was counted on DOMContentLoaded.
+ // We do not count the ad again on load.
+ await observeAdPreviouslyRecorded;
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "slow-page-load:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "slow-page-load:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "slow-page-load",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_on_load_event() {
+ resetTelemetry();
+
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "slow_loading_page_with_ads_on_load_event.html";
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(url)
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "slow-page-load:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "slow-page-load:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "slow-page-load",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_organic() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(getPageUrl(true), true)
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:organic:none": 1 },
+ "browser.search.withads.unknown": { "example:organic": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_new_window() {
+ resetTelemetry();
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ let url = getSERPUrl(getPageUrl(true));
+ BrowserTestUtils.loadURIString(win.gBrowser.selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(
+ win.gBrowser.selectedBrowser,
+ false,
+ url
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_track_ad_pages_without_ads() {
+ // Note: the above tests have already checked a page with no ad-urls.
+ resetTelemetry();
+
+ let tabs = [];
+
+ tabs.push(
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(getPageUrl(false))
+ )
+ );
+ tabs.push(
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(getPageUrl(true))
+ )
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 2 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+async function track_ad_click(testOrganic) {
+ // Note: the above tests have already checked a page with no ad-urls.
+ resetTelemetry();
+
+ let expectedScalarKey = `example:${testOrganic ? "organic" : "tagged"}`;
+ let expectedContentScalarKey = `example:${
+ testOrganic ? "organic:none" : "tagged:ff"
+ }`;
+ let tagged = testOrganic ? "false" : "true";
+ let partnerCode = testOrganic ? "" : "ff";
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(getPageUrl(true), testOrganic)
+ );
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { [expectedContentScalarKey]: 1 },
+ "browser.search.withads.unknown": {
+ [expectedScalarKey.replace("sap", "tagged")]: 1,
+ },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived(1);
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+ await promiseWaitForAdLinkCheck();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { [expectedContentScalarKey]: 1 },
+ "browser.search.withads.unknown": { [expectedScalarKey]: 1 },
+ "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ // Now go back, and click again.
+ pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ gBrowser.goBack();
+ await pageLoadPromise;
+ await promiseWaitForAdLinkCheck();
+
+ // We've gone back, so we register an extra display & if it is with ads or not.
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.tabhistory": { [expectedContentScalarKey]: 1 },
+ "browser.search.content.unknown": { [expectedContentScalarKey]: 1 },
+ "browser.search.withads.tabhistory": { [expectedScalarKey]: 1 },
+ "browser.search.withads.unknown": { [expectedScalarKey]: 1 },
+ "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "tabhistory",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived(2);
+
+ pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+ await promiseWaitForAdLinkCheck();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.tabhistory": { [expectedContentScalarKey]: 1 },
+ "browser.search.content.unknown": { [expectedContentScalarKey]: 1 },
+ "browser.search.withads.tabhistory": { [expectedScalarKey]: 1 },
+ "browser.search.withads.unknown": { [expectedScalarKey]: 1 },
+ "browser.search.adclicks.tabhistory": { [expectedScalarKey]: 1 },
+ "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "tabhistory",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function test_track_ad_click() {
+ await track_ad_click(false);
+});
+
+add_task(async function test_track_ad_click_organic() {
+ await track_ad_click(true);
+});
+
+add_task(async function test_track_ad_click_with_location_change_other_tab() {
+ resetTelemetry();
+ const url = getSERPUrl(getPageUrl(true));
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived();
+
+ const newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ "browser.search.adclicks.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(newTab);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources_in_content.js b/browser/components/search/test/browser/browser_search_telemetry_sources_in_content.js
new file mode 100644
index 0000000000..003b54cc8e
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_sources_in_content.js
@@ -0,0 +1,435 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * SearchSERPTelemetry tests related to in-content sources.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ included: {
+ parent: {
+ selector: ".refined-search-buttons",
+ },
+ children: [
+ {
+ selector: "a",
+ },
+ ],
+ },
+ topDown: true,
+ },
+ ],
+ },
+];
+
+function getSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetry.enabled", true]],
+ });
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ Services.prefs.setBoolPref("browser.search.log", true);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("browser.search.log");
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_source_opened_in_new_tab_via_middle_click() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#related-in-page",
+ { button: 1 },
+ tab1.linkedBrowser
+ );
+ let tab2 = await tabPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+add_task(async function test_source_opened_in_new_tab_via_target_blank() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+ // Note: the anchor element with id "related-new-tab" has a target=_blank
+ // attribute.
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#related-new-tab",
+ {},
+ tab1.linkedBrowser
+ );
+ let tab2 = await tabPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+add_task(async function test_source_opened_in_new_tab_via_context_menu() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#related-in-page",
+ {
+ button: 2,
+ type: "contextmenu",
+ },
+ tab1.linkedBrowser
+ );
+ await popupShownPromise;
+
+ let openLinkInNewTabMenuItem = contextMenu.querySelector(
+ "#context-openlinkintab"
+ );
+ contextMenu.activateItem(openLinkInNewTabMenuItem);
+
+ let tab2 = await tabPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+add_task(
+ async function test_source_refinement_button_clicked_no_partner_code() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#refined-search-button",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ }
+);
+
+add_task(
+ async function test_source_refinement_button_clicked_with_partner_code() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#refined-search-button-with-partner-code",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ }
+);
+
+// When a user opens a refinement button link in a new tab, we want the
+// source to be recorded as "follow_on_from_refine_on_SERP", not
+// "opened_in_new_tab", since the refinement button click provides greater
+// context.
+add_task(async function test_refinement_button_vs_opened_in_new_tab() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ let targetUrl =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+ ) + "searchTelemetryAd_searchbox_with_content.html?s=test2&abc=ff";
+
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#refined-search-button-with-partner-code",
+ { button: 1 },
+ tab1.linkedBrowser
+ );
+ let tab2 = await tabPromise;
+
+ await TestUtils.waitForCondition(() => {
+ return Glean.serp.impression?.testGetValue()?.length == 2;
+ }, "Should have two impressions.");
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources_navigation.js b/browser/components/search/test/browser/browser_search_telemetry_sources_navigation.js
new file mode 100644
index 0000000000..4269649992
--- /dev/null
+++ b/browser/components/search/test/browser/browser_search_telemetry_sources_navigation.js
@@ -0,0 +1,549 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Main tests for SearchSERPTelemetry - general engine visiting and link clicking.
+ */
+
+"use strict";
+
+const { SearchSERPTelemetry, SearchSERPTelemetryUtils } =
+ ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs");
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/,
+ queryParamName: "s",
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getPageUrl(useAdPage = false) {
+ let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html";
+ return `https://example.org/browser/browser/components/search/test/browser/${page}`;
+}
+
+/**
+ * Returns the index of the first search suggestion in the urlbar results.
+ *
+ * @returns {number} An index, or -1 if there are no search suggestions.
+ */
+async function getFirstSuggestionIndex() {
+ const matchCount = UrlbarTestUtils.getResultCount(window);
+ for (let i = 0; i < matchCount; i++) {
+ let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ if (
+ result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
+ result.searchParams.suggestion
+ ) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+// sharedData messages are only passed to the child on idle. Therefore
+// we wait for a few idles to try and ensure the messages have been able
+// to be passed across and handled.
+async function waitForIdle() {
+ for (let i = 0; i < 10; i++) {
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+ }
+}
+
+function resetTelemetry() {
+ searchCounts.clear();
+ Services.telemetry.clearScalars();
+ Services.fog.testResetFOG();
+}
+
+SearchTestUtils.init(this);
+
+let tab;
+
+add_setup(async function () {
+ searchCounts.clear();
+ Services.telemetry.clearScalars();
+
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.suggest.searches", true],
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ],
+ });
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ Services.prefs.setBoolPref("browser.search.log", true);
+
+ await SearchTestUtils.installSearchExtension(
+ {
+ search_url: getPageUrl(true),
+ search_url_get_params: "s={searchTerms}&abc=ff",
+ suggest_url:
+ "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs",
+ suggest_url_get_params: "query={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ Services.prefs.clearUserPref("browser.search.log");
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ Services.telemetry.clearScalars();
+ });
+});
+
+// These tests are consecutive and intentionally build on the results of the
+// previous test.
+
+async function loadSearchPage() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "searchSuggestion",
+ });
+ let idx = await getFirstSuggestionIndex();
+ Assert.greaterOrEqual(idx, 0, "there should be a first suggestion");
+ let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ while (idx--) {
+ EventUtils.sendKey("down");
+ }
+ EventUtils.sendKey("return");
+ await loadPromise;
+}
+
+add_task(async function test_search() {
+ Services.fog.testResetFOG();
+ // Load a page via the address bar.
+ await loadSearchPage();
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ ]);
+});
+
+add_task(async function test_reload() {
+ let promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ await promise;
+ await promiseWaitForAdLinkCheck();
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.content.reload": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ "browser.search.withads.reload": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "reload",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.content.reload": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ "browser.search.withads.reload": { "example:tagged": 1 },
+ "browser.search.adclicks.reload": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "reload",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+});
+
+let searchUrl;
+
+add_task(async function test_fresh_search() {
+ resetTelemetry();
+
+ // Load a page via the address bar.
+ await loadSearchPage();
+
+ searchUrl = tab.linkedBrowser.url;
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived(1);
+});
+
+add_task(async function test_click_ad() {
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ "browser.search.adclicks.urlbar": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+});
+
+add_task(async function test_go_back() {
+ let promise = BrowserTestUtils.waitForLocationChange(gBrowser, searchUrl);
+ tab.linkedBrowser.goBack();
+ await promise;
+ await promiseWaitForAdLinkCheck();
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.content.tabhistory": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ "browser.search.withads.tabhistory": { "example:tagged": 1 },
+ "browser.search.adclicks.urlbar": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived(2);
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.content.tabhistory": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ "browser.search.withads.tabhistory": { "example:tagged": 1 },
+ "browser.search.adclicks.urlbar": { "example:tagged": 1 },
+ "browser.search.adclicks.tabhistory": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+});
+
+// Conduct a search from the Urlbar with showSearchTerms enabled.
+add_task(async function test_fresh_search_with_urlbar_persisted() {
+ resetTelemetry();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.showSearchTerms.featureGate", true],
+ ["browser.urlbar.tipShownCount.searchTip_persist", 999],
+ ],
+ });
+
+ // Load a SERP once in order to show the search term in the Urlbar.
+ await loadSearchPage();
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived(1);
+
+ // Do another search from the context of the default SERP.
+ await loadSearchPage();
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ "other-Example.urlbar-persisted": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ "browser.search.content.urlbar_persisted": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar_persisted": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar_persisted",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ ]);
+ await promiseAdImpressionReceived(2);
+
+ // Click on an ad.
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+
+ await pageLoadPromise;
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ "other-Example.urlbar-persisted": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ "browser.search.content.urlbar_persisted": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar_persisted": { "example:tagged": 1 },
+ "browser.search.adclicks.urlbar_persisted": { "example:tagged": 1 },
+ }
+ );
+
+ assertImpressionEvents([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar_persisted",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_addEngine.js b/browser/components/search/test/browser/browser_searchbar_addEngine.js
new file mode 100644
index 0000000000..7d72d63dab
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_addEngine.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests the Add Search Engine option in the search bar.
+ */
+
+"use strict";
+
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+const searchPopup = document.getElementById("PopupSearchAutoComplete");
+let searchbar;
+
+add_setup(async function () {
+ searchbar = await gCUITestUtils.addSearchBar();
+
+ registerCleanupFunction(async function () {
+ gCUITestUtils.removeSearchBar();
+ Services.search.restoreDefaultEngines();
+ });
+});
+
+add_task(async function test_invalidEngine() {
+ let rootDir = getRootDirectory(gTestPath);
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ rootDir + "opensearch.html"
+ );
+ let promise = promiseEvent(searchPopup, "popupshown");
+ await EventUtils.synthesizeMouseAtCenter(
+ searchbar.querySelector(".searchbar-search-button"),
+ {}
+ );
+ await promise;
+
+ let addEngineList = searchPopup.querySelectorAll(
+ ".searchbar-engine-one-off-add-engine"
+ );
+ let item = addEngineList[addEngineList.length - 1];
+
+ await TestUtils.waitForCondition(
+ () => item.tooltipText.includes("engineInvalid"),
+ "Wait until the tooltip will be correct"
+ );
+ Assert.ok(true, "Last item should be the invalid entry");
+
+ let promptPromise = PromptTestUtils.waitForPrompt(tab.linkedBrowser, {
+ modalType: Ci.nsIPromptService.MODAL_TYPE_CONTENT,
+ promptType: "alert",
+ });
+
+ await EventUtils.synthesizeMouseAtCenter(item, {});
+
+ let prompt = await promptPromise;
+
+ Assert.ok(
+ prompt.ui.infoBody.textContent.includes(
+ "http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine_404.xml"
+ ),
+ "Should have included the url in the prompt body"
+ );
+
+ await PromptTestUtils.handlePrompt(prompt);
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_onOnlyDefaultEngine() {
+ info("Remove engines except default");
+ const defaultEngine = Services.search.defaultEngine;
+ const engines = await Services.search.getVisibleEngines();
+ for (const engine of engines) {
+ if (defaultEngine.name !== engine.name) {
+ await Services.search.removeEngine(engine);
+ }
+ }
+
+ info("Show popup");
+ const rootDir = getRootDirectory(gTestPath);
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ rootDir + "opensearch.html"
+ );
+ const onShown = promiseEvent(searchPopup, "popupshown");
+ await EventUtils.synthesizeMouseAtCenter(
+ searchbar.querySelector(".searchbar-search-button"),
+ {}
+ );
+ await onShown;
+
+ const addEngineList = searchPopup.querySelectorAll(
+ ".searchbar-engine-one-off-add-engine"
+ );
+ Assert.equal(addEngineList.length, 3, "Add engines should be shown");
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_context.js b/browser/components/search/test/browser/browser_searchbar_context.js
new file mode 100644
index 0000000000..4a3d20fc50
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_context.js
@@ -0,0 +1,246 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests the context menu for the search bar.
+ */
+
+"use strict";
+
+let win;
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "clipboardHelper",
+ "@mozilla.org/widget/clipboardhelper;1",
+ "nsIClipboardHelper"
+);
+
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ win = await BrowserTestUtils.openNewBrowserWindow();
+
+ // Disable suggestions for this test, so that we are not attempting to hit
+ // the network for suggestions when we don't need them.
+ SpecialPowers.pushPrefEnv({
+ set: [["browser.search.suggest.enabled", false]],
+ });
+
+ registerCleanupFunction(async function () {
+ await BrowserTestUtils.closeWindow(win);
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function test_emptybar() {
+ const searchbar = win.BrowserSearch.searchBar;
+ searchbar.focus();
+
+ let contextMenu = searchbar.querySelector(".textbox-contextmenu");
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ await EventUtils.synthesizeMouseAtCenter(
+ searchbar,
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await contextMenuPromise;
+
+ Assert.ok(
+ contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled,
+ "Should have disabled the cut menuitem"
+ );
+ Assert.ok(
+ contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled,
+ "Should have disabled the copy menuitem"
+ );
+ Assert.ok(
+ contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled,
+ "Should have disabled the delete menuitem"
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+});
+
+add_task(async function test_text_in_bar() {
+ const searchbar = win.BrowserSearch.searchBar;
+ searchbar.focus();
+
+ searchbar.value = "Test";
+ searchbar._textbox.editor.selectAll();
+
+ let contextMenu = searchbar.querySelector(".textbox-contextmenu");
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ await EventUtils.synthesizeMouseAtCenter(
+ searchbar,
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await contextMenuPromise;
+
+ Assert.ok(
+ !contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled,
+ "Should have enabled the cut menuitem"
+ );
+ Assert.ok(
+ !contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled,
+ "Should have enabled the copy menuitem"
+ );
+ Assert.ok(
+ !contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled,
+ "Should have enabled the delete menuitem"
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+});
+
+add_task(async function test_unfocused_emptybar() {
+ const searchbar = win.BrowserSearch.searchBar;
+ // clear searchbar value from previous test
+ searchbar.value = "";
+
+ // force focus onto another component
+ win.gURLBar.focus();
+
+ let contextMenu = searchbar.querySelector(".textbox-contextmenu");
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ searchbar.focus();
+ await EventUtils.synthesizeMouseAtCenter(
+ searchbar,
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await contextMenuPromise;
+
+ Assert.ok(
+ contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled,
+ "Should have disabled the cut menuitem"
+ );
+ Assert.ok(
+ contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled,
+ "Should have disabled the copy menuitem"
+ );
+ Assert.ok(
+ contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled,
+ "Should have disabled the delete menuitem"
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+});
+
+add_task(async function test_text_in_unfocused_bar() {
+ const searchbar = win.BrowserSearch.searchBar;
+
+ searchbar.value = "Test";
+
+ // force focus onto another component
+ win.gURLBar.focus();
+
+ let contextMenu = searchbar.querySelector(".textbox-contextmenu");
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ searchbar.focus();
+ await EventUtils.synthesizeMouseAtCenter(
+ searchbar,
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await contextMenuPromise;
+
+ Assert.ok(
+ !contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled,
+ "Should have enabled the cut menuitem"
+ );
+ Assert.ok(
+ !contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled,
+ "Should have enabled the copy menuitem"
+ );
+ Assert.ok(
+ !contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled,
+ "Should have enabled the delete menuitem"
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+});
+
+add_task(async function test_paste_and_go() {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ });
+
+ const searchbar = win.BrowserSearch.searchBar;
+
+ searchbar.value = "";
+ searchbar.focus();
+
+ const searchString = "test";
+
+ await SimpleTest.promiseClipboardChange(searchString, () => {
+ clipboardHelper.copyString(searchString);
+ });
+
+ let contextMenu = searchbar.querySelector(".textbox-contextmenu");
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await EventUtils.synthesizeMouseAtCenter(
+ searchbar,
+ { type: "contextmenu", button: 2 },
+ win
+ );
+ await contextMenuPromise;
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ searchbar.querySelector(".searchbar-paste-and-search").click();
+ await p;
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+
+ Assert.equal(
+ tab.linkedBrowser.currentURI.spec,
+ `https://example.com/?q=${searchString}`,
+ "Should have loaded the expected search page."
+ );
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_default.js b/browser/components/search/test/browser/browser_searchbar_default.js
new file mode 100644
index 0000000000..c1e9280932
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_default.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests the correct default engines in the search bar.
+ */
+
+"use strict";
+
+const { SearchSuggestionController } = ChromeUtils.importESModule(
+ "resource://gre/modules/SearchSuggestionController.sys.mjs"
+);
+
+const templateNormal = "https://example.com/?q=";
+const templatePrivate = "https://example.com/?query=";
+
+const searchPopup = document.getElementById("PopupSearchAutoComplete");
+
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.separatePrivateDefault", false]],
+ });
+
+ // Create two new search engines. Mark one as the default engine, so
+ // the test don't crash. We need to engines for this test as the searchbar
+ // doesn't display the default search engine among the one-off engines.
+ await SearchTestUtils.installSearchExtension({
+ name: "MozSearch1",
+ keyword: "mozalias",
+ });
+ await SearchTestUtils.installSearchExtension({
+ name: "MozSearch2",
+ keyword: "mozalias2",
+ search_url_get_params: "query={searchTerms}",
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.separatePrivateDefault.ui.enabled", true],
+ ["browser.search.separatePrivateDefault", false],
+ ],
+ });
+
+ let originalEngine = await Services.search.getDefault();
+ let originalPrivateEngine = await Services.search.getDefaultPrivate();
+
+ let engineDefault = Services.search.getEngineByName("MozSearch1");
+ await Services.search.setDefault(
+ engineDefault,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ registerCleanupFunction(async function () {
+ gCUITestUtils.removeSearchBar();
+ await Services.search.setDefault(
+ originalEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ await Services.search.setDefaultPrivate(
+ originalPrivateEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ });
+});
+
+async function doSearch(
+ win,
+ tab,
+ engineName,
+ templateUrl,
+ inputText = "query"
+) {
+ await searchInSearchbar(inputText, win);
+
+ Assert.ok(
+ win.BrowserSearch.searchBar.textbox.popup.searchbarEngineName
+ .getAttribute("value")
+ .includes(engineName),
+ "Should have the correct engine name displayed in the bar"
+ );
+
+ let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ await p;
+
+ Assert.equal(
+ tab.linkedBrowser.currentURI.spec,
+ templateUrl + inputText,
+ "Should have loaded the expected search page."
+ );
+}
+
+add_task(async function test_default_search() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ await doSearch(window, tab, "MozSearch1", templateNormal);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_default_search_private_no_separate() {
+ const win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ await doSearch(win, win.gBrowser.selectedTab, "MozSearch1", templateNormal);
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_default_search_private_no_separate() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.separatePrivateDefault", true]],
+ });
+
+ await Services.search.setDefaultPrivate(
+ Services.search.getEngineByName("MozSearch2"),
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ const win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ await doSearch(win, win.gBrowser.selectedTab, "MozSearch2", templatePrivate);
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_form_history() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ await FormHistoryTestUtils.clear("searchbar-history");
+ const gShortString = new Array(
+ SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH
+ )
+ .fill("a")
+ .join("");
+ let promiseAdd = TestUtils.topicObserved("satchel-storage-changed");
+ await doSearch(window, tab, "MozSearch1", templateNormal, gShortString);
+ await promiseAdd;
+ let entries = (await FormHistoryTestUtils.search("searchbar-history")).map(
+ entry => entry.value
+ );
+ Assert.deepEqual(
+ entries,
+ [gShortString],
+ "Should have stored search history"
+ );
+
+ await FormHistoryTestUtils.clear("searchbar-history");
+ const gLongString = new Array(
+ SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + 1
+ )
+ .fill("a")
+ .join("");
+ await doSearch(window, tab, "MozSearch1", templateNormal, gLongString);
+ // There's nothing we can wait for, since addition should not be happening.
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 500));
+ entries = (await FormHistoryTestUtils.search("searchbar-history")).map(
+ entry => entry.value
+ );
+ Assert.deepEqual(entries, [], "Should not find form history");
+
+ await FormHistoryTestUtils.clear("searchbar-history");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_searchbar_revert() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ await doSearch(window, tab, "MozSearch1", templateNormal, "testQuery");
+
+ let searchbar = window.BrowserSearch.searchBar;
+ is(
+ searchbar.value,
+ "testQuery",
+ "Search value should be the the last search"
+ );
+
+ // focus search bar
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ searchbar.focus();
+ await promise;
+
+ searchbar.value = "aQuery";
+ searchbar.value = "anotherQuery";
+
+ // close the panel using the escape key.
+ promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await promise;
+
+ is(searchbar.value, "anotherQuery", "The search value should be the same");
+ // revert the search bar value
+ EventUtils.synthesizeKey("KEY_Escape");
+ is(
+ searchbar.value,
+ "testQuery",
+ "The search value should have been reverted"
+ );
+
+ EventUtils.synthesizeKey("KEY_Escape");
+ is(searchbar.value, "testQuery", "The search value should be the same");
+
+ await doSearch(window, tab, "MozSearch1", templateNormal, "query");
+
+ is(searchbar.value, "query", "The search value should be query");
+ EventUtils.synthesizeKey("KEY_Escape");
+ is(searchbar.value, "query", "The search value should be the same");
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_enter.js b/browser/components/search/test/browser/browser_searchbar_enter.js
new file mode 100644
index 0000000000..030cf26fb2
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_enter.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test the behavior for enter key.
+
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ registerCleanupFunction(async function () {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function searchOnEnterSoon() {
+ info("Search on Enter as soon as typing a char");
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const browser = win.gBrowser.selectedBrowser;
+ const browserSearch = win.BrowserSearch;
+
+ const onPageHide = SpecialPowers.spawn(browser, [], () => {
+ return new Promise(resolve => {
+ content.addEventListener("pagehide", () => {
+ resolve();
+ });
+ });
+ });
+ const onResult = SpecialPowers.spawn(browser, [], () => {
+ return new Promise(resolve => {
+ content.addEventListener("keyup", () => {
+ resolve("keyup");
+ });
+ content.addEventListener("unload", () => {
+ resolve("unload");
+ });
+ });
+ });
+
+ info("Focus on the search bar");
+ const searchBarTextBox = browserSearch.searchBar.textbox;
+ EventUtils.synthesizeMouseAtCenter(searchBarTextBox, {}, win);
+ const ownerDocument = browser.ownerDocument;
+ is(ownerDocument.activeElement, searchBarTextBox, "The search bar has focus");
+
+ info("Keydown a char and Enter");
+ EventUtils.synthesizeKey("x", { type: "keydown" }, win);
+ EventUtils.synthesizeKey("KEY_Enter", { type: "keydown" }, win);
+
+ info("Wait for pagehide event in the content");
+ await onPageHide;
+ is(
+ ownerDocument.activeElement,
+ searchBarTextBox,
+ "The search bar still has focus"
+ );
+
+ // Keyup both key as soon as pagehide event happens.
+ EventUtils.synthesizeKey("x", { type: "keyup" }, win);
+ EventUtils.synthesizeKey("KEY_Enter", { type: "keyup" }, win);
+
+ await TestUtils.waitForCondition(
+ () => ownerDocument.activeElement === browser,
+ "Wait for focus to be moved to the browser"
+ );
+ info("The focus is moved to the browser");
+
+ // Check whether keyup event is not captured before unload event happens.
+ const result = await onResult;
+ is(result, "unload", "Keyup event is not captured");
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function typeCharWhileProcessingEnter() {
+ info("Typing a char while processing enter key");
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const browser = win.gBrowser.selectedBrowser;
+ const searchBar = win.BrowserSearch.searchBar;
+
+ const SEARCH_WORD = "test";
+ const onLoad = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ `https://example.com/?q=${SEARCH_WORD}`
+ );
+ searchBar.textbox.focus();
+ searchBar.textbox.value = SEARCH_WORD;
+
+ info("Keydown Enter");
+ EventUtils.synthesizeKey("KEY_Enter", { type: "keydown" }, win);
+ await TestUtils.waitForCondition(
+ () => searchBar._needBrowserFocusAtEnterKeyUp,
+ "Wait for starting process for the enter key"
+ );
+
+ info("Keydown a char");
+ EventUtils.synthesizeKey("x", { type: "keydown" }, win);
+
+ info("Keyup both");
+ EventUtils.synthesizeKey("x", { type: "keyup" }, win);
+ EventUtils.synthesizeKey("KEY_Enter", { type: "keyup" }, win);
+
+ Assert.equal(
+ searchBar.textbox.value,
+ SEARCH_WORD,
+ "The value of searchbar is correct"
+ );
+
+ await onLoad;
+ Assert.ok("Browser loaded the correct url");
+
+ // Cleanup.
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function keyupEnterWhilePressingMeta() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const browser = win.gBrowser.selectedBrowser;
+ const searchBar = win.BrowserSearch.searchBar;
+
+ info("Keydown Meta+Enter");
+ searchBar.textbox.focus();
+ searchBar.textbox.value = "";
+ EventUtils.synthesizeKey(
+ "KEY_Enter",
+ { type: "keydown", metaKey: true },
+ win
+ );
+
+ // Pressing Enter key while pressing Meta key, and next, even when releasing
+ // Enter key before releasing Meta key, the keyup event is not fired.
+ // Therefor, we fire Meta keyup event only.
+ info("Keyup Meta");
+ EventUtils.synthesizeKey("KEY_Meta", { type: "keyup" }, win);
+
+ await TestUtils.waitForCondition(
+ () => browser.ownerDocument.activeElement === browser,
+ "Wait for focus to be moved to the browser"
+ );
+ info("The focus is moved to the browser");
+
+ info("Check whether we can input on the search bar");
+ searchBar.textbox.focus();
+ EventUtils.synthesizeKey("a", {}, win);
+ is(searchBar.textbox.value, "a", "Can input a char");
+
+ // Cleanup.
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js b/browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js
new file mode 100644
index 0000000000..de78beabd6
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js
@@ -0,0 +1,644 @@
+// Tests that keyboard navigation in the search panel works as designed.
+
+const searchPopup = document.getElementById("PopupSearchAutoComplete");
+
+const kValues = ["foo1", "foo2", "foo3"];
+const kUserValue = "foo";
+
+function getOpenSearchItems() {
+ let os = [];
+
+ let addEngineList = searchPopup.searchOneOffsContainer.querySelector(
+ ".search-add-engines"
+ );
+ for (
+ let item = addEngineList.firstElementChild;
+ item;
+ item = item.nextElementSibling
+ ) {
+ os.push(item);
+ }
+
+ return os;
+}
+
+let searchbar;
+let textbox;
+
+async function checkHeader(engine) {
+ // The header can be updated after getting the engine, so we may have to
+ // wait for it.
+ let header = searchPopup.searchbarEngineName;
+ if (!header.getAttribute("value").includes(engine.name)) {
+ await new Promise(resolve => {
+ let observer = new MutationObserver(() => {
+ observer.disconnect();
+ resolve();
+ });
+ observer.observe(searchPopup.searchbarEngineName, {
+ attributes: true,
+ attributeFilter: ["value"],
+ });
+ });
+ }
+ Assert.ok(
+ header.getAttribute("value").includes(engine.name),
+ "Should have the correct engine name displayed in the header"
+ );
+}
+
+add_setup(async function () {
+ searchbar = await gCUITestUtils.addSearchBar();
+ textbox = searchbar.textbox;
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine.xml",
+ setAsDefault: true,
+ });
+ // First cleanup the form history in case other tests left things there.
+ info("cleanup the search history");
+ await FormHistory.update({ op: "remove", fieldname: "searchbar-history" });
+
+ info("adding search history values: " + kValues);
+ let addOps = kValues.map(value => {
+ return { op: "add", fieldname: "searchbar-history", value };
+ });
+ await FormHistory.update(addOps);
+
+ textbox.value = kUserValue;
+
+ registerCleanupFunction(async () => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function test_arrows() {
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ searchbar.focus();
+ await promise;
+ is(
+ textbox.mController.searchString,
+ kUserValue,
+ "The search string should be 'foo'"
+ );
+
+ // Check the initial state of the panel before sending keyboard events.
+ is(searchPopup.matchCount, kValues.length, "There should be 3 suggestions");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // The tests will be less meaningful if the first, second, last, and
+ // before-last one-off buttons aren't different. We should always have more
+ // than 4 default engines, but it's safer to check this assumption.
+ let oneOffs = getOneOffs();
+ ok(oneOffs.length >= 4, "we have at least 4 one-off buttons displayed");
+
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // The down arrow should first go through the suggestions.
+ for (let i = 0; i < kValues.length; ++i) {
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(
+ searchPopup.selectedIndex,
+ i,
+ "the suggestion at index " + i + " should be selected"
+ );
+ is(
+ textbox.value,
+ kValues[i],
+ "the textfield value should be " + kValues[i]
+ );
+ await checkHeader(Services.search.defaultEngine);
+ }
+
+ // Pressing down again should remove suggestion selection and change the text
+ // field value back to what the user typed, and select the first one-off.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(
+ textbox.value,
+ kUserValue,
+ "the textfield value should be back to initial value"
+ );
+
+ // now cycle through the one-off items, the first one is already selected.
+ for (let i = 0; i < oneOffs.length; ++i) {
+ let oneOffButton = oneOffs[i];
+ is(
+ textbox.selectedButton,
+ oneOffButton,
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ await checkHeader(oneOffButton.engine);
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ }
+
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+ await checkHeader(Services.search.defaultEngine);
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+
+ // We should now be back to the initial situation.
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ await checkHeader(Services.search.defaultEngine);
+
+ info("now test the up arrow key");
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+ await checkHeader(Services.search.defaultEngine);
+
+ // cycle through the one-off items, the first one is already selected.
+ for (let i = oneOffs.length; i; --i) {
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ let oneOffButton = oneOffs[i - 1];
+ is(
+ textbox.selectedButton,
+ oneOffButton,
+ "the one-off button #" + i + " should be selected"
+ );
+ await checkHeader(oneOffButton.engine);
+ }
+
+ // Another press on up should clear the one-off selection and select the
+ // last suggestion.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ for (let i = kValues.length - 1; i >= 0; --i) {
+ is(
+ searchPopup.selectedIndex,
+ i,
+ "the suggestion at index " + i + " should be selected"
+ );
+ is(
+ textbox.value,
+ kValues[i],
+ "the textfield value should be " + kValues[i]
+ );
+ await checkHeader(Services.search.defaultEngine);
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ }
+
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(
+ textbox.value,
+ kUserValue,
+ "the textfield value should be back to initial value"
+ );
+});
+
+add_task(async function test_typing_clears_button_selection() {
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "the search bar should be focused"
+ ); // from the previous test.
+ ok(!textbox.selectedButton, "no button should be selected");
+
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // Type a character.
+ EventUtils.sendString("a");
+ ok(!textbox.selectedButton, "the settings item should be de-selected");
+
+ // Remove the character.
+ EventUtils.synthesizeKey("KEY_Backspace");
+});
+
+add_task(async function test_tab() {
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "the search bar should be focused"
+ ); // from the previous test.
+
+ let oneOffs = getOneOffs();
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // Pressing tab should select the first one-off without selecting suggestions.
+ // now cycle through the one-off items, the first one is already selected.
+ for (let i = 0; i < oneOffs.length; ++i) {
+ EventUtils.synthesizeKey("KEY_Tab");
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ }
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, kUserValue, "the textfield value should be unmodified");
+
+ // One more <tab> selects the settings button.
+ EventUtils.synthesizeKey("KEY_Tab");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // Pressing tab again should close the panel...
+ let promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Tab");
+ await promise;
+
+ // ... and move the focus out of the searchbox.
+ isnot(
+ Services.focus.focusedElement,
+ textbox.inputField,
+ "the search bar no longer be focused"
+ );
+});
+
+add_task(async function test_shift_tab() {
+ // First reopen the panel.
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ searchbar.focus();
+ await promise;
+
+ let oneOffs = getOneOffs();
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // Press up once to select the last button.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // Press up again to select the last one-off button.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+
+ // Pressing shift+tab should cycle through the one-off items.
+ for (let i = oneOffs.length - 1; i >= 0; --i) {
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ if (i) {
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ }
+ }
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, kUserValue, "the textfield value should be unmodified");
+
+ // Pressing shift+tab again should close the panel...
+ promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ await promise;
+
+ // ... and move the focus out of the searchbox.
+ isnot(
+ Services.focus.focusedElement,
+ textbox.inputField,
+ "the search bar no longer be focused"
+ );
+});
+
+add_task(async function test_alt_down() {
+ // First refocus the panel.
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ searchbar.focus();
+ await promise;
+
+ // close the panel using the escape key.
+ promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await promise;
+
+ // check that alt+down opens the panel...
+ promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ await promise;
+
+ // ... and does nothing else.
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, kUserValue, "the textfield value should be unmodified");
+
+ // Pressing alt+down should select the first one-off without selecting suggestions
+ // and cycle through the one-off items.
+ let oneOffs = getOneOffs();
+ for (let i = 0; i < oneOffs.length; ++i) {
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ }
+
+ // One more alt+down keypress and nothing should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // another one and the first one-off should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[0],
+ "the first one-off button should be selected"
+ );
+});
+
+add_task(async function test_alt_up() {
+ // close the panel using the escape key.
+ let promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await promise;
+ ok(
+ !textbox.selectedButton,
+ "no one-off button should be selected after closing the panel"
+ );
+
+ // check that alt+up opens the panel...
+ promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ await promise;
+
+ // ... and does nothing else.
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, kUserValue, "the textfield value should be unmodified");
+
+ // Pressing alt+up should select the last one-off without selecting suggestions
+ // and cycle up through the one-off items.
+ let oneOffs = getOneOffs();
+ for (let i = oneOffs.length - 1; i >= 0; --i) {
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ }
+
+ // One more alt+down keypress and nothing should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // another one and the last one-off should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[oneOffs.length - 1],
+ "the last one-off button should be selected"
+ );
+
+ // Cleanup for the next test.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ ok(!textbox.selectedButton, "no one-off should be selected anymore");
+});
+
+add_task(async function test_accel_down() {
+ // Pressing accel+down should select the next visible search engine, without
+ // selecting suggestions.
+ let engines = await Services.search.getVisibleEngines();
+ let current = Services.search.defaultEngine;
+ let currIdx = -1;
+ for (let i = 0, l = engines.length; i < l; ++i) {
+ if (engines[i].name == current.name) {
+ currIdx = i;
+ break;
+ }
+ }
+ for (let i = 0, l = engines.length; i < l; ++i) {
+ EventUtils.synthesizeKey("KEY_ArrowDown", { accelKey: true });
+ await SearchTestUtils.promiseSearchNotification(
+ "engine-default",
+ "browser-search-engine-modified"
+ );
+ let expected = engines[++currIdx % engines.length];
+ is(
+ Services.search.defaultEngine.name,
+ expected.name,
+ "Default engine should have changed"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ }
+ Services.search.defaultEngine = current;
+});
+
+add_task(async function test_accel_up() {
+ // Pressing accel+down should select the previous visible search engine, without
+ // selecting suggestions.
+ let engines = await Services.search.getVisibleEngines();
+ let current = Services.search.defaultEngine;
+ let currIdx = -1;
+ for (let i = 0, l = engines.length; i < l; ++i) {
+ if (engines[i].name == current.name) {
+ currIdx = i;
+ break;
+ }
+ }
+ for (let i = 0, l = engines.length; i < l; ++i) {
+ EventUtils.synthesizeKey("KEY_ArrowUp", { accelKey: true });
+ await SearchTestUtils.promiseSearchNotification(
+ "engine-default",
+ "browser-search-engine-modified"
+ );
+ let expected =
+ engines[--currIdx < 0 ? (currIdx = engines.length - 1) : currIdx];
+ is(
+ Services.search.defaultEngine.name,
+ expected.name,
+ "Default engine should have changed"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ }
+ Services.search.defaultEngine = current;
+});
+
+add_task(async function test_tab_and_arrows() {
+ // Check the initial state is as expected.
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, kUserValue, "the textfield value should be unmodified");
+
+ // After pressing down, the first sugggestion should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(searchPopup.selectedIndex, 0, "first suggestion should be selected");
+ is(textbox.value, kValues[0], "the textfield value should have changed");
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // After pressing tab, the first one-off should be selected,
+ // and no suggestion should be selected.
+ let oneOffs = getOneOffs();
+ EventUtils.synthesizeKey("KEY_Tab");
+ is(
+ textbox.selectedButton,
+ oneOffs[0],
+ "the first one-off button should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // After pressing down, the second one-off should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(
+ textbox.selectedButton,
+ oneOffs[1],
+ "the second one-off button should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // After pressing right, the third one-off should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowRight");
+ is(
+ textbox.selectedButton,
+ oneOffs[2],
+ "the third one-off button should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // After pressing left, the second one-off should be selected again.
+ EventUtils.synthesizeKey("KEY_ArrowLeft");
+ is(
+ textbox.selectedButton,
+ oneOffs[1],
+ "the second one-off button should be selected again"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // After pressing up, the first one-off should be selected again.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ is(
+ textbox.selectedButton,
+ oneOffs[0],
+ "the first one-off button should be selected again"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // After pressing up again, the last suggestion should be selected.
+ // the textfield value back to the user-typed value, and still the first one-off
+ // selected.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ is(
+ searchPopup.selectedIndex,
+ kValues.length - 1,
+ "last suggestion should be selected"
+ );
+ is(
+ textbox.value,
+ kValues[kValues.length - 1],
+ "the textfield value should match the suggestion"
+ );
+ is(textbox.selectedButton, null, "no one-off button should be selected");
+
+ // Now pressing down should select the first one-off.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(
+ textbox.selectedButton,
+ oneOffs[0],
+ "the first one-off button should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "there should be no selected suggestion");
+
+ // Finally close the panel.
+ let promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+});
+
+add_task(async function test_open_search() {
+ let rootDir = getRootDirectory(gTestPath);
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ rootDir + "opensearch.html"
+ );
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ searchbar.focus();
+ await promise;
+
+ let engines = searchPopup.querySelectorAll(
+ ".searchbar-engine-one-off-add-engine"
+ );
+ is(engines.length, 3, "the opensearch.html page exposes 3 engines");
+
+ // Check that there's initially no selection.
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ ok(!textbox.selectedButton, "no button should be selected");
+
+ // Pressing up once selects the setting button...
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // ...and then pressing up selects open search engines.
+ for (let i = engines.length; i; --i) {
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ let selectedButton = textbox.selectedButton;
+ is(
+ selectedButton,
+ engines[i - 1],
+ "the engine #" + i + " should be selected"
+ );
+ ok(
+ selectedButton.classList.contains("searchbar-engine-one-off-add-engine"),
+ "the button is themed as an add engine"
+ );
+ }
+
+ // Pressing up again should select the last one-off button.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ const allOneOffs = getOneOffs();
+ is(
+ textbox.selectedButton,
+ allOneOffs[allOneOffs.length - engines.length - 1],
+ "the last one-off button should be selected"
+ );
+
+ info("now check that the down key navigates open search items as expected");
+ for (let i = 0; i < engines.length; ++i) {
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(
+ textbox.selectedButton,
+ engines[i],
+ "the engine #" + (i + 1) + " should be selected"
+ );
+ }
+
+ // Pressing down on the last engine item selects the settings button.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function cleanup() {
+ info("removing search history values: " + kValues);
+ let removeOps = kValues.map(value => {
+ return { op: "remove", fieldname: "searchbar-history", value };
+ });
+ await FormHistory.update(removeOps);
+
+ textbox.value = "";
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_openpopup.js b/browser/components/search/test/browser/browser_searchbar_openpopup.js
new file mode 100644
index 0000000000..a8fe11dbf4
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_openpopup.js
@@ -0,0 +1,794 @@
+// Tests that the suggestion popup appears at the right times in response to
+// focus and user events (mouse, keyboard, drop).
+
+const searchPopup = document.getElementById("PopupSearchAutoComplete");
+const kValues = ["long text", "long text 2", "long text 3"];
+
+async function endCustomizing(aWindow = window) {
+ if (aWindow.document.documentElement.getAttribute("customizing") != "true") {
+ return true;
+ }
+ let eventPromise = BrowserTestUtils.waitForEvent(
+ aWindow.gNavToolbox,
+ "aftercustomization"
+ );
+ aWindow.gCustomizeMode.exit();
+ return eventPromise;
+}
+
+async function startCustomizing(aWindow = window) {
+ if (aWindow.document.documentElement.getAttribute("customizing") == "true") {
+ return true;
+ }
+ let eventPromise = BrowserTestUtils.waitForEvent(
+ aWindow.gNavToolbox,
+ "customizationready"
+ );
+ aWindow.gCustomizeMode.enter();
+ return eventPromise;
+}
+
+let searchbar;
+let textbox;
+let searchIcon;
+let goButton;
+let engine;
+
+add_setup(async function () {
+ searchbar = await gCUITestUtils.addSearchBar();
+ textbox = searchbar.textbox;
+ searchIcon = searchbar.querySelector(".searchbar-search-button");
+ goButton = searchbar.querySelector(".search-go-button");
+
+ engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine.xml",
+ setAsDefault: true,
+ });
+
+ await clearSearchbarHistory();
+
+ let addOps = kValues.map(value => {
+ return { op: "add", fieldname: "searchbar-history", value };
+ });
+ info("adding search history values: " + kValues);
+ await FormHistory.update(addOps);
+
+ registerCleanupFunction(async () => {
+ await clearSearchbarHistory();
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+// Adds a task that shouldn't show the search suggestions popup.
+function add_no_popup_task(task) {
+ add_task(async function () {
+ let sawPopup = false;
+ function listener() {
+ sawPopup = true;
+ }
+
+ info("Entering test " + task.name);
+ searchPopup.addEventListener("popupshowing", listener);
+ await task();
+ searchPopup.removeEventListener("popupshowing", listener);
+ ok(!sawPopup, "Shouldn't have seen the suggestions popup");
+ info("Leaving test " + task.name);
+ });
+}
+
+// Simulates the full set of events for a context click
+function context_click(target) {
+ for (let event of ["mousedown", "contextmenu", "mouseup"]) {
+ EventUtils.synthesizeMouseAtCenter(target, { type: event, button: 2 });
+ }
+}
+
+// Right clicking the icon should not open the popup.
+add_no_popup_task(async function open_icon_context() {
+ gURLBar.focus();
+ let toolbarPopup = document.getElementById("toolbar-context-menu");
+
+ let promise = promiseEvent(toolbarPopup, "popupshown");
+ context_click(searchIcon);
+ await promise;
+
+ promise = promiseEvent(toolbarPopup, "popuphidden");
+ toolbarPopup.hidePopup();
+ await promise;
+});
+
+// With no text in the search box left clicking the icon should open the popup.
+// Clicking the icon again should hide the popup and not show it again.
+add_task(async function open_empty() {
+ gURLBar.focus();
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Clicking icon");
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ await promise;
+ is(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should only show the settings"
+ );
+ is(textbox.mController.searchString, "", "Should be an empty search string");
+
+ let image = searchPopup.querySelector(".searchbar-engine-image");
+ Assert.equal(
+ image.src,
+ engine.getIconURLBySize(16, 16),
+ "Should have the correct icon"
+ );
+
+ // By giving the textbox some text any next attempt to open the search popup
+ // from the click handler will try to search for this text.
+ textbox.value = "foo";
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+
+ info("Hiding popup");
+ await EventUtils.promiseNativeMouseEventAndWaitForEvent({
+ type: "click",
+ target: searchIcon,
+ atCenter: true,
+ eventTypeToWait: "mouseup",
+ });
+ await promise;
+
+ is(
+ textbox.mController.searchString,
+ "",
+ "Should not have started to search for the new text"
+ );
+
+ // Cancel the search if it started.
+ if (textbox.mController.searchString != "") {
+ textbox.mController.stopSearch();
+ }
+
+ textbox.value = "";
+});
+
+// With no text in the search box left clicking it should not open the popup.
+add_no_popup_task(function click_doesnt_open_popup() {
+ gURLBar.focus();
+
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 0, "Should have selected all of the text");
+});
+
+// Left clicking in a non-empty search box when unfocused should focus it and open the popup.
+add_task(async function click_opens_popup() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+
+ textbox.value = "";
+});
+
+add_task(async function open_empty_hiddenOneOffs() {
+ // Disable all the engines but the current one and check the oneoffs.
+ let defaultEngine = await Services.search.getDefault();
+ let engines = (await Services.search.getVisibleEngines()).filter(
+ e => e.name != defaultEngine.name
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.hiddenOneOffs", engines.map(e => e.name).join(",")]],
+ });
+
+ textbox.value = "foo";
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ await promise;
+
+ Assert.ok(
+ searchPopup.searchOneOffsContainer.hasAttribute("hidden"),
+ "The one-offs buttons container should have the hidden attribute."
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(searchPopup.searchOneOffsContainer),
+ "The one-off buttons container should be hidden."
+ );
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+
+ info("Hiding popup");
+ await EventUtils.promiseNativeMouseEventAndWaitForEvent({
+ type: "click",
+ target: searchIcon,
+ atCenter: true,
+ eventTypeToWait: "mouseup",
+ });
+ await promise;
+
+ await SpecialPowers.popPrefEnv();
+ textbox.value = "";
+});
+
+// Right clicking in a non-empty search box when unfocused should open the edit context menu.
+add_no_popup_task(async function right_click_doesnt_open_popup() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ // Can't wait for an event on the actual menu since it is created
+ // lazily the first time it is displayed.
+ let promise = new Promise(resolve => {
+ let listener = event => {
+ if (searchbar._menupopup && event.target == searchbar._menupopup) {
+ resolve(searchbar._menupopup);
+ }
+ };
+ window.addEventListener("popupshown", listener);
+ });
+ context_click(textbox);
+ let contextPopup = await promise;
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(contextPopup, "popuphidden");
+ contextPopup.hidePopup();
+ await promise;
+
+ textbox.value = "";
+});
+
+// Moving focus away from the search box should close the popup
+add_task(async function focus_change_closes_popup() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ let promise2 = promiseEvent(searchbar.textbox, "blur");
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ await promise;
+ await promise2;
+
+ textbox.value = "";
+});
+
+// Moving focus away from the search box should close the small popup
+add_task(async function focus_change_closes_small_popup() {
+ gURLBar.focus();
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ // For some reason sending the mouse event immediately doesn't open the popup.
+ SimpleTest.executeSoon(() => {
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ });
+ await promise;
+ is(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the small popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ let promise2 = promiseEvent(searchbar.textbox, "blur");
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ await promise;
+ await promise2;
+});
+
+// Pressing escape should close the popup.
+add_task(async function escape_closes_popup() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await promise;
+
+ textbox.value = "";
+});
+
+// Pressing contextmenu should close the popup.
+add_task(async function contextmenu_closes_popup() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ let contextPopup = searchbar._menupopup;
+ let contextMenuShownPromise = promiseEvent(contextPopup, "popupshown");
+ let searchPopupHiddenPromise = promiseEvent(searchPopup, "popuphidden");
+ context_click(textbox);
+ await contextMenuShownPromise;
+ await searchPopupHiddenPromise;
+
+ let contextMenuHiddenPromise = promiseEvent(contextPopup, "popuphidden");
+ contextPopup.hidePopup();
+ await contextMenuHiddenPromise;
+
+ textbox.value = "";
+});
+
+// Tabbing to the search box should open the popup if it contains text.
+add_task(async function tab_opens_popup() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeKey("KEY_Tab");
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+
+ textbox.value = "";
+});
+
+// Tabbing to the search box should not open the popup if it doesn't contain text.
+add_no_popup_task(function tab_doesnt_open_popup() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ EventUtils.synthesizeKey("KEY_Tab");
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ textbox.value = "";
+});
+
+// Switching back to the window when the search box has focus from mouse should not open the popup.
+add_task(async function refocus_window_doesnt_open_popup_mouse() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(searchbar, {});
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ let newWin = OpenBrowserWindow();
+ await new Promise(resolve => waitForFocus(resolve, newWin));
+ await promise;
+
+ function listener() {
+ ok(false, "Should not have shown the popup.");
+ }
+ searchPopup.addEventListener("popupshowing", listener);
+
+ promise = promiseEvent(searchbar.textbox, "focus");
+ newWin.close();
+ await promise;
+
+ // Wait a few ticks to allow any focus handlers to show the popup if they are going to.
+ await new Promise(resolve => executeSoon(resolve));
+ await new Promise(resolve => executeSoon(resolve));
+ await new Promise(resolve => executeSoon(resolve));
+
+ searchPopup.removeEventListener("popupshowing", listener);
+ textbox.value = "";
+});
+
+// Switching back to the window when the search box has focus from keyboard should not open the popup.
+add_task(async function refocus_window_doesnt_open_popup_keyboard() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeKey("KEY_Tab");
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ let newWin = OpenBrowserWindow();
+ await new Promise(resolve => waitForFocus(resolve, newWin));
+ await promise;
+
+ function listener() {
+ ok(false, "Should not have shown the popup.");
+ }
+ searchPopup.addEventListener("popupshowing", listener);
+
+ promise = promiseEvent(searchbar.textbox, "focus");
+ newWin.close();
+ await promise;
+
+ // Wait a few ticks to allow any focus handlers to show the popup if they are going to.
+ await new Promise(resolve => executeSoon(resolve));
+ await new Promise(resolve => executeSoon(resolve));
+ await new Promise(resolve => executeSoon(resolve));
+
+ searchPopup.removeEventListener("popupshowing", listener);
+ textbox.value = "";
+});
+
+// Clicking the search go button shouldn't open the popup
+add_no_popup_task(async function search_go_doesnt_open_popup() {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+
+ gURLBar.focus();
+ textbox.value = "foo";
+ searchbar.updateGoButtonVisibility();
+
+ let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ EventUtils.synthesizeMouseAtCenter(goButton, {});
+ await promise;
+
+ textbox.value = "";
+ gBrowser.removeCurrentTab();
+});
+
+// Clicks outside the search popup should close the popup but not consume the click.
+add_task(async function dont_consume_clicks() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ is(textbox.selectionStart, 0, "Should have selected all of the text");
+ is(textbox.selectionEnd, 3, "Should have selected all of the text");
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ await EventUtils.promiseNativeMouseEventAndWaitForEvent({
+ type: "click",
+ target: gURLBar.inputField,
+ atCenter: true,
+ eventTypeToWait: "mouseup",
+ });
+ await promise;
+
+ is(
+ Services.focus.focusedElement,
+ gURLBar.inputField,
+ "Should have focused the URL bar"
+ );
+
+ textbox.value = "";
+});
+
+// Dropping text to the searchbar should open the popup
+add_task(async function drop_opens_popup() {
+ CustomizableUI.addWidgetToArea("home-button", "nav-bar");
+ // The previous task leaves focus in the URL bar. However, in that case drags
+ // can be interpreted as being selection drags by the drag manager, which
+ // breaks the drag synthesis from EventUtils.js below. To avoid this, focus
+ // the browser content instead.
+ let focusEventPromise = BrowserTestUtils.waitForEvent(
+ gBrowser.selectedBrowser,
+ "focus"
+ );
+ gBrowser.selectedBrowser.focus();
+ await focusEventPromise;
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ // Use a source for the drop that is outside of the search bar area, to avoid
+ // it receiving a mousedown and causing the popup to sometimes open.
+ let homeButton = document.getElementById("home-button");
+ EventUtils.synthesizeDrop(
+ homeButton,
+ textbox,
+ [[{ type: "text/plain", data: "foo" }]],
+ "move",
+ window
+ );
+ await promise;
+
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "Should have focused the search bar"
+ );
+ promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+
+ textbox.value = "";
+ CustomizableUI.removeWidgetFromArea("home-button");
+});
+
+// Moving the caret using the cursor keys should not close the popup.
+add_task(async function dont_rollup_oncaretmove() {
+ gURLBar.focus();
+ textbox.value = "long text";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(textbox, {});
+ await promise;
+
+ // Deselect the text
+ EventUtils.synthesizeKey("KEY_ArrowRight");
+ is(
+ textbox.selectionStart,
+ 9,
+ "Should have moved the caret (selectionStart after deselect right)"
+ );
+ is(
+ textbox.selectionEnd,
+ 9,
+ "Should have moved the caret (selectionEnd after deselect right)"
+ );
+ is(searchPopup.state, "open", "Popup should still be open");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft");
+ is(
+ textbox.selectionStart,
+ 8,
+ "Should have moved the caret (selectionStart after left)"
+ );
+ is(
+ textbox.selectionEnd,
+ 8,
+ "Should have moved the caret (selectionEnd after left)"
+ );
+ is(searchPopup.state, "open", "Popup should still be open");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight");
+ is(
+ textbox.selectionStart,
+ 9,
+ "Should have moved the caret (selectionStart after right)"
+ );
+ is(
+ textbox.selectionEnd,
+ 9,
+ "Should have moved the caret (selectionEnd after right)"
+ );
+ is(searchPopup.state, "open", "Popup should still be open");
+
+ // Ensure caret movement works while a suggestion is selected.
+ is(textbox.popup.selectedIndex, -1, "No selected item in list");
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(textbox.popup.selectedIndex, 0, "Selected item in list");
+ is(
+ textbox.selectionStart,
+ 9,
+ "Should have moved the caret to the end (selectionStart after selection)"
+ );
+ is(
+ textbox.selectionEnd,
+ 9,
+ "Should have moved the caret to the end (selectionEnd after selection)"
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft");
+ is(
+ textbox.selectionStart,
+ 8,
+ "Should have moved the caret again (selectionStart after left)"
+ );
+ is(
+ textbox.selectionEnd,
+ 8,
+ "Should have moved the caret again (selectionEnd after left)"
+ );
+ is(searchPopup.state, "open", "Popup should still be open");
+
+ EventUtils.synthesizeKey("KEY_ArrowLeft");
+ is(
+ textbox.selectionStart,
+ 7,
+ "Should have moved the caret (selectionStart after left)"
+ );
+ is(
+ textbox.selectionEnd,
+ 7,
+ "Should have moved the caret (selectionEnd after left)"
+ );
+ is(searchPopup.state, "open", "Popup should still be open");
+
+ EventUtils.synthesizeKey("KEY_ArrowRight");
+ is(
+ textbox.selectionStart,
+ 8,
+ "Should have moved the caret (selectionStart after right)"
+ );
+ is(
+ textbox.selectionEnd,
+ 8,
+ "Should have moved the caret (selectionEnd after right)"
+ );
+ is(searchPopup.state, "open", "Popup should still be open");
+
+ if (!navigator.platform.includes("Mac")) {
+ EventUtils.synthesizeKey("KEY_Home");
+ is(
+ textbox.selectionStart,
+ 0,
+ "Should have moved the caret (selectionStart after home)"
+ );
+ is(
+ textbox.selectionEnd,
+ 0,
+ "Should have moved the caret (selectionEnd after home)"
+ );
+ is(searchPopup.state, "open", "Popup should still be open");
+ }
+
+ // Close the popup again
+ promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await promise;
+
+ textbox.value = "";
+});
+
+// Entering customization mode shouldn't open the popup.
+add_task(async function dont_open_in_customization() {
+ gURLBar.focus();
+ textbox.value = "foo";
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ EventUtils.synthesizeKey("KEY_Tab");
+ await promise;
+ isnot(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the full popup"
+ );
+
+ info("Entering customization mode");
+ let sawPopup = false;
+ function listener() {
+ sawPopup = true;
+ }
+ searchPopup.addEventListener("popupshowing", listener);
+ await gCUITestUtils.openMainMenu();
+ promise = promiseEvent(searchPopup, "popuphidden");
+ await startCustomizing();
+ await promise;
+
+ searchPopup.removeEventListener("popupshowing", listener);
+ ok(!sawPopup, "Shouldn't have seen the suggestions popup");
+
+ await endCustomizing();
+ textbox.value = "";
+});
+
+add_task(async function cleanup() {
+ info("removing search history values: " + kValues);
+ let removeOps = kValues.map(value => {
+ return { op: "remove", fieldname: "searchbar-history", value };
+ });
+ FormHistory.update(removeOps);
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_results.js b/browser/components/search/test/browser/browser_searchbar_results.js
new file mode 100644
index 0000000000..95bb5674c7
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_results.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ await gCUITestUtils.addSearchBar();
+ await clearSearchbarHistory();
+
+ await SearchTestUtils.installSearchExtension(
+ {
+ id: "test",
+ name: "test",
+ suggest_url:
+ "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs",
+ suggest_url_get_params: "query={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+
+ registerCleanupFunction(async () => {
+ await clearSearchbarHistory();
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+async function check_results(input, expected) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ async browser => {
+ let popup = await searchInSearchbar(input);
+
+ const listItemElems = popup.richlistbox.querySelectorAll(
+ ".autocomplete-richlistitem"
+ );
+
+ Assert.deepEqual(
+ Array.from(listItemElems)
+ .filter(e => !e.collapsed)
+ .map(e => e.getAttribute("title")),
+ expected,
+ "Should have received the expected suggestions"
+ );
+
+ // Now visit the search to put an item in form history.
+ let p = BrowserTestUtils.browserLoaded(browser);
+ EventUtils.synthesizeKey("KEY_Enter");
+ await p;
+ }
+ );
+}
+
+add_task(async function test_utf8_results() {
+ await check_results("。", ["。foo", "。bar"]);
+
+ // The first run added the entry into form history, check that is correct
+ // as well.
+ await check_results("。", ["。", "。foo", "。bar"]);
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js b/browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js
new file mode 100644
index 0000000000..e2ddadabd9
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js
@@ -0,0 +1,449 @@
+// Tests that keyboard navigation in the search panel works as designed.
+
+const searchPopup = document.getElementById("PopupSearchAutoComplete");
+
+const kValues = ["foo1", "foo2", "foo3"];
+
+function getOpenSearchItems() {
+ let os = [];
+
+ let addEngineList = searchPopup.querySelector(".search-add-engines");
+ for (
+ let item = addEngineList.firstElementChild;
+ item;
+ item = item.nextElementSibling
+ ) {
+ os.push(item);
+ }
+
+ return os;
+}
+
+let searchbar;
+let textbox;
+let searchIcon;
+
+add_setup(async function () {
+ searchbar = await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+ textbox = searchbar.textbox;
+ searchIcon = searchbar.querySelector(".searchbar-search-button");
+
+ await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "testEngine.xml",
+ setAsDefault: true,
+ });
+
+ // First cleanup the form history in case other tests left things there.
+ info("cleanup the search history");
+ await FormHistory.update({ op: "remove", fieldname: "searchbar-history" });
+
+ info("adding search history values: " + kValues);
+ let addOps = kValues.map(value => {
+ return { op: "add", fieldname: "searchbar-history", value };
+ });
+ await FormHistory.update(addOps);
+
+ registerCleanupFunction(async () => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function test_arrows() {
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ await promise;
+ info(
+ "textbox.mController.searchString = " + textbox.mController.searchString
+ );
+ is(textbox.mController.searchString, "", "The search string should be empty");
+
+ // Check the initial state of the panel before sending keyboard events.
+ is(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the small popup"
+ );
+ // Having suggestions populated (but hidden) is important, because if there
+ // are none we can't ensure the keyboard events don't reach them.
+ is(searchPopup.matchCount, kValues.length, "There should be 3 suggestions");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // The tests will be less meaningful if the first, second, last, and
+ // before-last one-off buttons aren't different. We should always have more
+ // than 4 default engines, but it's safer to check this assumption.
+ let oneOffs = getOneOffs();
+ ok(oneOffs.length >= 4, "we have at least 4 one-off buttons displayed");
+
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // Pressing should select the first one-off.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, "", "the textfield value should be unmodified");
+
+ // now cycle through the one-off items, the first one is already selected.
+ for (let i = 0; i < oneOffs.length; ++i) {
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ }
+
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+
+ // We should now be back to the initial situation.
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ info("now test the up arrow key");
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // cycle through the one-off items, the first one is already selected.
+ for (let i = oneOffs.length; i; --i) {
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ is(
+ textbox.selectedButton,
+ oneOffs[i - 1],
+ "the one-off button #" + i + " should be selected"
+ );
+ }
+
+ // Another press on up should clear the one-off selection.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, "", "the textfield value should be unmodified");
+});
+
+add_task(async function test_tab() {
+ is(
+ Services.focus.focusedElement,
+ textbox,
+ "the search bar should be focused"
+ ); // from the previous test.
+
+ let oneOffs = getOneOffs();
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // Pressing tab should select the first one-off without selecting suggestions.
+ // now cycle through the one-off items, the first one is already selected.
+ for (let i = 0; i < oneOffs.length; ++i) {
+ EventUtils.synthesizeKey("KEY_Tab");
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ }
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, "", "the textfield value should be unmodified");
+
+ // One more <tab> selects the settings button.
+ EventUtils.synthesizeKey("KEY_Tab");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // Pressing tab again should close the panel...
+ let promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Tab");
+ await promise;
+
+ // ... and move the focus out of the searchbox.
+ isnot(
+ Services.focus.focusedElement,
+ textbox,
+ "the search bar no longer be focused"
+ );
+});
+
+add_task(async function test_shift_tab() {
+ // First reopen the panel.
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ SimpleTest.executeSoon(() => {
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ });
+ await promise;
+
+ let oneOffs = getOneOffs();
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the small popup"
+ );
+
+ // Press up once to select the last button.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // Press up again to select the last one-off button.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+
+ // Pressing shift+tab should cycle through the one-off items.
+ for (let i = oneOffs.length - 1; i >= 0; --i) {
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ if (i) {
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ }
+ }
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, "", "the textfield value should be unmodified");
+
+ // Pressing shift+tab again should close the panel...
+ promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ await promise;
+
+ // ... and move the focus out of the searchbox.
+ isnot(
+ Services.focus.focusedElement,
+ textbox,
+ "the search bar no longer be focused"
+ );
+});
+
+add_task(async function test_alt_down() {
+ // First reopen the panel.
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ SimpleTest.executeSoon(() => {
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ });
+ await promise;
+
+ // and check it's in a correct initial state.
+ is(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the small popup"
+ );
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, "", "the textfield value should be unmodified");
+
+ // Pressing alt+down should select the first one-off without selecting suggestions
+ // and cycle through the one-off items.
+ let oneOffs = getOneOffs();
+ for (let i = 0; i < oneOffs.length; ++i) {
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ }
+
+ // One more alt+down keypress and nothing should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // another one and the first one-off should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[0],
+ "the first one-off button should be selected"
+ );
+
+ // Clear the selection with an alt+up keypress
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+});
+
+add_task(async function test_alt_up() {
+ // Check the initial state of the panel
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, "", "the textfield value should be unmodified");
+
+ // Pressing alt+up should select the last one-off without selecting suggestions
+ // and cycle up through the one-off items.
+ let oneOffs = getOneOffs();
+ for (let i = oneOffs.length - 1; i >= 0; --i) {
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[i],
+ "the one-off button #" + (i + 1) + " should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ }
+
+ // One more alt+down keypress and nothing should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+
+ // another one and the last one-off should be selected.
+ EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true });
+ is(
+ textbox.selectedButton,
+ oneOffs[oneOffs.length - 1],
+ "the last one-off button should be selected"
+ );
+
+ // Cleanup for the next test.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ ok(!textbox.selectedButton, "no one-off should be selected anymore");
+});
+
+add_task(async function test_tab_and_arrows() {
+ // Check the initial state is as expected.
+ ok(!textbox.selectedButton, "no one-off button should be selected");
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ is(textbox.value, "", "the textfield value should be unmodified");
+
+ // After pressing down, the first one-off should be selected.
+ let oneOffs = getOneOffs();
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(
+ textbox.selectedButton,
+ oneOffs[0],
+ "the first one-off button should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // After pressing tab, the second one-off should be selected.
+ EventUtils.synthesizeKey("KEY_Tab");
+ is(
+ textbox.selectedButton,
+ oneOffs[1],
+ "the second one-off button should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // After pressing up, the first one-off should be selected again.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ is(
+ textbox.selectedButton,
+ oneOffs[0],
+ "the first one-off button should be selected"
+ );
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+
+ // Finally close the panel.
+ let promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+});
+
+add_task(async function test_open_search() {
+ let rootDir = getRootDirectory(gTestPath);
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ rootDir + "opensearch.html"
+ );
+
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ EventUtils.synthesizeMouseAtCenter(searchIcon, {});
+ await promise;
+ is(
+ searchPopup.getAttribute("showonlysettings"),
+ "true",
+ "Should show the small popup"
+ );
+
+ let engines;
+ await TestUtils.waitForCondition(() => {
+ engines = searchPopup.querySelectorAll(
+ ".searchbar-engine-one-off-add-engine"
+ );
+ return engines.length == 3;
+ }, "Should expose three engines");
+
+ // Check that there's initially no selection.
+ is(searchPopup.selectedIndex, -1, "no suggestion should be selected");
+ ok(!textbox.selectedButton, "no button should be selected");
+
+ // Pressing up once selects the setting button...
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ // ...and then pressing up selects open search engines.
+ for (let i = engines.length; i; --i) {
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ let selectedButton = textbox.selectedButton;
+ is(
+ selectedButton,
+ engines[i - 1],
+ "the engine #" + i + " should be selected"
+ );
+ ok(
+ selectedButton.classList.contains("searchbar-engine-one-off-add-engine"),
+ "the button is themed as an add engine"
+ );
+ }
+
+ // Pressing up again should select the last one-off button.
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ const allOneOffs = getOneOffs();
+ is(
+ textbox.selectedButton,
+ allOneOffs[allOneOffs.length - engines.length - 1],
+ "the last one-off button should be selected"
+ );
+
+ info("now check that the down key navigates open search items as expected");
+ for (let i = 0; i < engines.length; ++i) {
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(
+ textbox.selectedButton,
+ engines[i],
+ "the engine #" + (i + 1) + " should be selected"
+ );
+ }
+
+ // Pressing down on the last engine item selects the settings button.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ ok(
+ textbox.selectedButton.classList.contains("search-setting-button"),
+ "the settings item should be selected"
+ );
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ searchPopup.hidePopup();
+ await promise;
+
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function cleanup() {
+ info("removing search history values: " + kValues);
+ let removeOps = kValues.map(value => {
+ return { op: "remove", fieldname: "searchbar-history", value };
+ });
+ FormHistory.update(removeOps);
+});
diff --git a/browser/components/search/test/browser/browser_searchbar_widths.js b/browser/components/search/test/browser/browser_searchbar_widths.js
new file mode 100644
index 0000000000..3e17ebf833
--- /dev/null
+++ b/browser/components/search/test/browser/browser_searchbar_widths.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Check that when the searchbar has a specific width, opening a new window
+// honours that specific width.
+add_task(async function test_searchbar_width_persistence() {
+ await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(async function () {
+ gCUITestUtils.removeSearchBar();
+ });
+
+ // Really, we should use the splitter, but drag/drop is hard and fragile in
+ // tests, so let's just fake it real quick:
+ let container = BrowserSearch.searchBar.parentNode;
+ // There's no width attribute set initially, just grab the info from layout:
+ let oldWidth = container.getBoundingClientRect().width;
+ let newWidth = "" + Math.round(oldWidth * 2);
+ container.setAttribute("width", newWidth);
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ let otherBar = win.BrowserSearch.searchBar;
+ ok(otherBar, "Should have a search bar in the other window");
+ if (otherBar) {
+ is(
+ otherBar.parentNode.getAttribute("width"),
+ newWidth,
+ "Should have matching width"
+ );
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/components/search/test/browser/browser_tooManyEnginesOffered.js b/browser/components/search/test/browser/browser_tooManyEnginesOffered.js
new file mode 100644
index 0000000000..89647f9854
--- /dev/null
+++ b/browser/components/search/test/browser/browser_tooManyEnginesOffered.js
@@ -0,0 +1,68 @@
+"use strict";
+
+// This test makes sure that when a page offers many search engines,
+// a limited number of add-engine items will be shown in the searchbar.
+
+const searchPopup = document.getElementById("PopupSearchAutoComplete");
+
+add_task(async function test_setup() {
+ await gCUITestUtils.addSearchBar();
+
+ await Services.search.init();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function test() {
+ let searchbar = BrowserSearch.searchBar;
+
+ let rootDir = getRootDirectory(gTestPath);
+ let url = rootDir + "tooManyEnginesOffered.html";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ // Open the search popup.
+ let promise = promiseEvent(searchPopup, "popupshown");
+ info("Opening search panel");
+ searchbar.focus();
+ // In TV we may try opening too early, when the searchbar is not ready yet.
+ await TestUtils.waitForCondition(
+ () => BrowserSearch.searchBar.textbox.controller.input,
+ "Wait for the searchbar controller to connect"
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ await promise;
+
+ const addEngineList = searchPopup.oneOffButtons._getAddEngines();
+ Assert.equal(
+ addEngineList.length,
+ 6,
+ "Expected number of engines retrieved from web page"
+ );
+
+ const displayedAddEngineList =
+ searchPopup.oneOffButtons.buttons.querySelectorAll(
+ ".searchbar-engine-one-off-add-engine"
+ );
+ Assert.equal(
+ displayedAddEngineList.length,
+ searchPopup.oneOffButtons._maxInlineAddEngines,
+ "Expected number of engines displayed on popup"
+ );
+
+ for (let i = 0; i < displayedAddEngineList.length; i++) {
+ const engine = addEngineList[i];
+ const item = displayedAddEngineList[i];
+ Assert.equal(
+ item.getAttribute("engine-name"),
+ engine.title,
+ "Expected engine is displaying"
+ );
+ }
+
+ promise = promiseEvent(searchPopup, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape", {}, searchPopup.ownerGlobal);
+ await promise;
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/components/search/test/browser/browser_trending_suggestions.js b/browser/components/search/test/browser/browser_trending_suggestions.js
new file mode 100644
index 0000000000..5db741e816
--- /dev/null
+++ b/browser/components/search/test/browser/browser_trending_suggestions.js
@@ -0,0 +1,189 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const CONFIG_DEFAULT = [
+ {
+ webExtension: { id: "basic@search.mozilla.org" },
+ urls: {
+ trending: {
+ fullPath:
+ "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs",
+ query: "",
+ },
+ },
+ appliesTo: [{ included: { everywhere: true } }],
+ default: "yes",
+ },
+ {
+ webExtension: { id: "private@search.mozilla.org" },
+ appliesTo: [{ included: { everywhere: true } }],
+ default: "yes",
+ },
+];
+
+SearchTestUtils.init(this);
+
+add_setup(async () => {
+ // Use engines in test directory
+ let searchExtensions = getChromeDir(getResolvedURI(gTestPath));
+ searchExtensions.append("search-engines");
+ await SearchTestUtils.useMochitestEngines(searchExtensions);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.suggest.searches", true]],
+ });
+
+ SearchTestUtils.useMockIdleService();
+ await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT);
+
+ registerCleanupFunction(async () => {
+ let settingsWritten = SearchTestUtils.promiseSearchNotification(
+ "write-settings-to-disk-complete"
+ );
+ await SearchTestUtils.updateRemoteSettingsConfig();
+ await settingsWritten;
+ });
+});
+
+add_task(async function test_trending_results() {
+ await check_results({
+ featureEnabled: true,
+ searchMode: "@basic ",
+ expectedResults: 2,
+ });
+ await check_results({
+ featureEnabled: true,
+ requireSearchModeEnabled: false,
+ expectedResults: 2,
+ });
+ await check_results({
+ featureEnabled: true,
+ requireSearchModeEnabled: false,
+ searchMode: "@basic ",
+ expectedResults: 2,
+ });
+ await check_results({
+ featureEnabled: false,
+ searchMode: "@basic ",
+ expectedResults: 0,
+ });
+ await check_results({
+ featureEnabled: false,
+ expectedResults: 0,
+ });
+ await check_results({
+ featureEnabled: false,
+ requireSearchModeEnabled: false,
+ expectedResults: 0,
+ });
+ await check_results({
+ featureEnabled: false,
+ requireSearchModeEnabled: false,
+ searchMode: "@basic ",
+ expectedResults: 0,
+ });
+
+ // The private engine is not configured with any trending url.
+ await check_results({
+ featureEnabled: true,
+ searchMode: "@private ",
+ expectedResults: 0,
+ });
+
+ // Check we can configure the maximum number of results.
+ await check_results({
+ featureEnabled: true,
+ searchMode: "@basic ",
+ maxResultsSearchMode: 5,
+ expectedResults: 5,
+ });
+ await check_results({
+ featureEnabled: true,
+ requireSearchModeEnabled: false,
+ maxResultsNoSearchMode: 5,
+ expectedResults: 5,
+ });
+});
+
+add_task(async function test_trending_telemetry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.trending.featureGate", true],
+ ["browser.urlbar.trending.requireSearchMode", false],
+ ],
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "",
+ waitForFocus: SimpleTest.waitForFocus,
+ });
+
+ await UrlbarTestUtils.promisePopupClose(window, () => {
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ EventUtils.synthesizeKey("KEY_Enter");
+ });
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(scalars, "urlbar.picked.trending", 0, 1);
+});
+
+async function check_results({
+ featureEnabled = false,
+ requireSearchModeEnabled = true,
+ searchMode = "",
+ expectedResults = 0,
+ maxResultsSearchMode = 2,
+ maxResultsNoSearchMode = 2,
+}) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.trending.maxResultsSearchMode", maxResultsSearchMode],
+ [
+ "browser.urlbar.trending.maxResultsNoSearchMode",
+ maxResultsNoSearchMode,
+ ],
+ ["browser.urlbar.trending.featureGate", featureEnabled],
+ ["browser.urlbar.trending.requireSearchMode", requireSearchModeEnabled],
+ ],
+ });
+
+ // If we are not in a search mode and there are no results. The urlbar
+ // will not open.
+ if (!searchMode && !expectedResults) {
+ window.gURLBar.inputField.focus();
+ Assert.ok(!UrlbarTestUtils.isPopupOpen(window));
+ return;
+ }
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchMode,
+ waitForFocus: SimpleTest.waitForFocus,
+ });
+
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ expectedResults,
+ "We matched the expected number of results"
+ );
+
+ if (expectedResults) {
+ for (let i = 0; i < expectedResults; i++) {
+ let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH);
+ Assert.equal(result.providerName, "SearchSuggestions");
+ Assert.equal(result.payload.engine, "basic");
+ Assert.equal(result.payload.trending, true);
+ }
+ }
+
+ if (searchMode) {
+ await UrlbarTestUtils.exitSearchMode(window);
+ }
+ await UrlbarTestUtils.promisePopupClose(window, () => {
+ EventUtils.synthesizeKey("KEY_Escape");
+ });
+ await SpecialPowers.popPrefEnv();
+}
diff --git a/browser/components/search/test/browser/cacheable.html b/browser/components/search/test/browser/cacheable.html
new file mode 100644
index 0000000000..8aac4a0f16
--- /dev/null
+++ b/browser/components/search/test/browser/cacheable.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Cacheable Page</title>
+</head>
+<body>
+ <p>This page is cacheable.</p>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/cacheable.html^headers^ b/browser/components/search/test/browser/cacheable.html^headers^
new file mode 100644
index 0000000000..6f34caa8f2
--- /dev/null
+++ b/browser/components/search/test/browser/cacheable.html^headers^
@@ -0,0 +1 @@
+Cache-Control: max-age=3600
diff --git a/browser/components/search/test/browser/contentSearchUI.html b/browser/components/search/test/browser/contentSearchUI.html
new file mode 100644
index 0000000000..09abe822b2
--- /dev/null
+++ b/browser/components/search/test/browser/contentSearchUI.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<html>
+<head>
+<meta charset="utf-8">
+<script type="application/javascript"
+ src="chrome://browser/content/contentSearchUI.js">
+</script>
+<link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css"/>
+<meta http-equiv="Content-Security-Policy" content="default-src data: chrome:; object-src 'none'"/>
+</head>
+<body>
+
+<div id="container"><input type="text" value=""/></div>
+
+<script src="chrome://mochitests/content/browser/browser/components/search/test/browser/contentSearchUI.js">
+</script>
+
+</body>
+</html>
diff --git a/browser/components/search/test/browser/contentSearchUI.js b/browser/components/search/test/browser/contentSearchUI.js
new file mode 100644
index 0000000000..7ccf0b6a6d
--- /dev/null
+++ b/browser/components/search/test/browser/contentSearchUI.js
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* import-globals-from ../../content/contentSearchUI.js */
+var input = document.querySelector("input");
+var gController = new ContentSearchUIController(
+ input,
+ input.parentNode,
+ "test",
+ "test"
+);
diff --git a/browser/components/search/test/browser/discovery.html b/browser/components/search/test/browser/discovery.html
new file mode 100644
index 0000000000..0c73d592fe
--- /dev/null
+++ b/browser/components/search/test/browser/discovery.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+ <head id="linkparent">
+ <meta charset="utf-8">
+ <title>Autodiscovery Test</title>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/browser/components/search/test/browser/google_codes/browser.ini b/browser/components/search/test/browser/google_codes/browser.ini
new file mode 100644
index 0000000000..5496ddb5c9
--- /dev/null
+++ b/browser/components/search/test/browser/google_codes/browser.ini
@@ -0,0 +1,5 @@
+[DEFAULT]
+prefs =
+ browser.search.region='DE'
+
+[../browser_google_behavior.js]
diff --git a/browser/components/search/test/browser/head.js b/browser/components/search/test/browser/head.js
new file mode 100644
index 0000000000..730ea45c12
--- /dev/null
+++ b/browser/components/search/test/browser/head.js
@@ -0,0 +1,395 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ADLINK_CHECK_TIMEOUT_MS:
+ "resource:///actors/SearchSERPTelemetryChild.sys.mjs",
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+ CustomizableUITestUtils:
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs",
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ FormHistoryTestUtils:
+ "resource://testing-common/FormHistoryTestUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => {
+ const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlbarTestUtils.sys.mjs"
+ );
+ module.init(this);
+ return module;
+});
+
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+AddonTestUtils.initMochitest(this);
+SearchTestUtils.init(this);
+
+const UUID_REGEX =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+/**
+ * Recursively compare two objects and check that every property of expectedObj has the same value
+ * on actualObj.
+ *
+ * @param {object} expectedObj
+ * The expected object to find.
+ * @param {object} actualObj
+ * The object to inspect.
+ * @param {string} name
+ * The name of the engine, used for test detail logging.
+ */
+function isSubObjectOf(expectedObj, actualObj, name) {
+ for (let prop in expectedObj) {
+ if (typeof expectedObj[prop] == "function") {
+ continue;
+ }
+ if (expectedObj[prop] instanceof Object) {
+ is(
+ actualObj[prop].length,
+ expectedObj[prop].length,
+ name + "[" + prop + "]"
+ );
+ isSubObjectOf(
+ expectedObj[prop],
+ actualObj[prop],
+ name + "[" + prop + "]"
+ );
+ } else {
+ is(actualObj[prop], expectedObj[prop], name + "[" + prop + "]");
+ }
+ }
+}
+
+function getLocale() {
+ return Services.locale.requestedLocale || undefined;
+}
+
+function promiseEvent(aTarget, aEventName, aPreventDefault) {
+ function cancelEvent(event) {
+ if (aPreventDefault) {
+ event.preventDefault();
+ }
+
+ return true;
+ }
+
+ return BrowserTestUtils.waitForEvent(aTarget, aEventName, false, cancelEvent);
+}
+
+// Get an array of the one-off buttons.
+function getOneOffs() {
+ let oneOffs = [];
+ let searchPopup = document.getElementById("PopupSearchAutoComplete");
+ let oneOffsContainer = searchPopup.searchOneOffsContainer;
+ let oneOff = oneOffsContainer.querySelector(".search-panel-one-offs");
+ for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) {
+ if (oneOff.nodeType == Node.ELEMENT_NODE) {
+ oneOffs.push(oneOff);
+ }
+ }
+ return oneOffs;
+}
+
+async function typeInSearchField(browser, text, fieldName) {
+ await SpecialPowers.spawn(
+ browser,
+ [[fieldName, text]],
+ async function ([contentFieldName, contentText]) {
+ // Put the focus on the search box.
+ let searchInput = content.document.getElementById(contentFieldName);
+ searchInput.focus();
+ searchInput.value = contentText;
+ }
+ );
+}
+
+XPCOMUtils.defineLazyGetter(this, "searchCounts", () => {
+ return Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
+});
+
+XPCOMUtils.defineLazyGetter(this, "SEARCH_AD_CLICK_SCALARS", () => {
+ const sources = [
+ ...BrowserSearchTelemetry.KNOWN_SEARCH_SOURCES.values(),
+ "unknown",
+ ];
+ return [
+ ...sources.map(v => `browser.search.withads.${v}`),
+ ...sources.map(v => `browser.search.adclicks.${v}`),
+ ];
+});
+
+// Ad links are processed after a small delay. We need to allow tests to wait
+// for that before checking telemetry, otherwise the received values may be
+// too small in some cases.
+function promiseWaitForAdLinkCheck() {
+ return new Promise(resolve =>
+ /* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */
+ setTimeout(resolve, ADLINK_CHECK_TIMEOUT_MS)
+ );
+}
+
+async function assertSearchSourcesTelemetry(
+ expectedHistograms,
+ expectedScalars
+) {
+ let histSnapshot = {};
+ let scalars = {};
+
+ // This used to rely on the implied 100ms initial timer of
+ // TestUtils.waitForCondition. See bug 1515466.
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ await TestUtils.waitForCondition(() => {
+ histSnapshot = searchCounts.snapshot();
+ return (
+ Object.getOwnPropertyNames(histSnapshot).length ==
+ Object.getOwnPropertyNames(expectedHistograms).length
+ );
+ }, "should have the correct number of histograms");
+
+ if (Object.entries(expectedScalars).length) {
+ await TestUtils.waitForCondition(() => {
+ scalars =
+ Services.telemetry.getSnapshotForKeyedScalars("main", false).parent ||
+ {};
+ return Object.getOwnPropertyNames(expectedScalars).every(
+ scalar => scalar in scalars
+ );
+ }, "should have the expected keyed scalars");
+ }
+
+ Assert.equal(
+ Object.getOwnPropertyNames(histSnapshot).length,
+ Object.getOwnPropertyNames(expectedHistograms).length,
+ "Should only have one key"
+ );
+
+ for (let [key, value] of Object.entries(expectedHistograms)) {
+ Assert.ok(
+ key in histSnapshot,
+ `Histogram should have the expected key: ${key}`
+ );
+ Assert.equal(
+ histSnapshot[key].sum,
+ value,
+ `Should have counted the correct number of visits for ${key}`
+ );
+ }
+
+ for (let [name, value] of Object.entries(expectedScalars)) {
+ Assert.ok(name in scalars, `Scalar ${name} should have been added.`);
+ Assert.deepEqual(
+ scalars[name],
+ value,
+ `Should have counted the correct number of visits for ${name}`
+ );
+ }
+
+ for (let name of SEARCH_AD_CLICK_SCALARS) {
+ Assert.equal(
+ name in scalars,
+ name in expectedScalars,
+ `Should have matched ${name} in scalars and expectedScalars`
+ );
+ }
+}
+
+async function searchInSearchbar(inputText, win = window) {
+ await new Promise(r => waitForFocus(r, win));
+ let sb = win.BrowserSearch.searchBar;
+ // Write the search query in the searchbar.
+ sb.focus();
+ sb.value = inputText;
+ sb.textbox.controller.startSearch(inputText);
+ // Wait for the popup to show.
+ await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown");
+ // And then for the search to complete.
+ await TestUtils.waitForCondition(
+ () =>
+ sb.textbox.controller.searchStatus >=
+ Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH,
+ "The search in the searchbar must complete."
+ );
+ return sb.textbox.popup;
+}
+
+function clearSearchbarHistory(win = window) {
+ info("cleanup the search history");
+ return FormHistory.update({ op: "remove", fieldname: "searchbar-history" });
+}
+
+function resetTelemetry() {
+ searchCounts.clear();
+ Services.telemetry.clearScalars();
+ Services.fog.testResetFOG();
+}
+
+/**
+ * First checks that we get the correct number of recorded Glean impression events
+ * and the recorded Glean impression events have the correct keys and values.
+ *
+ * Then it checks that there are the the correct engagement events associated with the
+ * impression events.
+ *
+ * @param {Array} expectedEvents The expected impression events whose keys and
+ * values we use to validate the recorded Glean impression events.
+ */
+function assertImpressionEvents(expectedEvents) {
+ // A single test might run assertImpressionEvents more than once
+ // so the Set needs to be cleared or else the impression event
+ // check will throw.
+ const impressionIdsSet = new Set();
+
+ let recordedImpressions = Glean.serp.impression.testGetValue() ?? [];
+
+ Assert.equal(
+ recordedImpressions.length,
+ expectedEvents.length,
+ "Should have the correct number of impressions."
+ );
+
+ // Assert the impression events.
+ for (let [idx, expectedEvent] of expectedEvents.entries()) {
+ let impressionId = recordedImpressions[idx].extra.impression_id;
+ Assert.ok(
+ UUID_REGEX.test(impressionId),
+ "Should have an impression_id with a valid UUID."
+ );
+
+ Assert.ok(
+ !impressionIdsSet.has(impressionId),
+ "Should have a unique impression_id."
+ );
+
+ impressionIdsSet.add(impressionId);
+
+ // If we want to use deepEqual checks, we have to add the impressionId
+ // to each impression since they are randomly generated at runtime.
+ expectedEvent.impression.impression_id = impressionId;
+
+ Assert.deepEqual(
+ recordedImpressions[idx].extra,
+ expectedEvent.impression,
+ "Should have matched impression values."
+ );
+
+ // Once the impression check is sufficient, add the impression_id to
+ // each of the expected engagements for later deep equal checks.
+ if (expectedEvent.engagements) {
+ for (let expectedEngagment of expectedEvent.engagements) {
+ expectedEngagment.impression_id = impressionId;
+ }
+ }
+ }
+
+ // Group engagement events into separate array fetchable by their
+ // impression_id.
+ let recordedEngagements = Glean.serp.engagement.testGetValue() ?? [];
+ let idToEngagements = new Map();
+ let totalExpectedEngagements = 0;
+
+ for (let recordedEngagement of recordedEngagements) {
+ let impressionId = recordedEngagement.extra.impression_id;
+ Assert.ok(
+ impressionId,
+ "Should have an engagement event with an impression_id"
+ );
+
+ let arr = idToEngagements.get(impressionId) ?? [];
+ arr.push(recordedEngagement.extra);
+
+ idToEngagements.set(impressionId, arr);
+ }
+
+ // Assert the engagement events.
+ for (let expectedEvent of expectedEvents) {
+ let impressionId = expectedEvent.impression.impression_id;
+ let expectedEngagements = expectedEvent.engagements;
+ if (expectedEngagements) {
+ let recorded = idToEngagements.get(impressionId);
+ Assert.deepEqual(
+ recorded,
+ expectedEngagements,
+ "Should have matched engagement values."
+ );
+ totalExpectedEngagements += expectedEngagements.length;
+ }
+ }
+
+ Assert.equal(
+ recordedEngagements.length,
+ totalExpectedEngagements,
+ "Should have equal number of engagements."
+ );
+}
+
+function assertAdImpressionEvents(expectedAdImpressions) {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ let impressions = Glean.serp.impression.testGetValue() ?? [];
+
+ Assert.equal(impressions.length, 1, "Should have a SERP impression event.");
+ Assert.equal(
+ adImpressions.length,
+ expectedAdImpressions.length,
+ "Should have equal number of ad impression events."
+ );
+
+ expectedAdImpressions = expectedAdImpressions.map(expectedAdImpression => {
+ expectedAdImpression.impression_id = impressions[0].extra.impression_id;
+ return expectedAdImpression;
+ });
+
+ for (let [index, expectedAdImpression] of expectedAdImpressions.entries()) {
+ Assert.deepEqual(
+ adImpressions[index]?.extra,
+ expectedAdImpression,
+ "Should have equal values for an ad impression."
+ );
+ }
+}
+
+function assertAbandonmentEvent(expectedAbandonment) {
+ let recordedAbandonment = Glean.serp.abandonment.testGetValue() ?? [];
+
+ Assert.equal(
+ recordedAbandonment[0].extra.reason,
+ expectedAbandonment.abandonment.reason,
+ "Should have the correct abandonment reason."
+ );
+}
+
+async function promiseAdImpressionReceived(num) {
+ if (num) {
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length == num;
+ }, `Should have received ${num} ad impressions.`);
+ }
+ return TestUtils.waitForCondition(() => {
+ let adImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ return adImpressions.length;
+ }, "Should have received an ad impression.");
+}
+
+async function waitForPageWithAdImpressions() {
+ return new Promise(resolve => {
+ let listener = win => {
+ Services.obs.removeObserver(
+ listener,
+ "reported-page-with-ad-impressions"
+ );
+ resolve();
+ };
+ Services.obs.addObserver(listener, "reported-page-with-ad-impressions");
+ });
+}
+
+registerCleanupFunction(async () => {
+ await PlacesUtils.history.clear();
+});
diff --git a/browser/components/search/test/browser/mozsearch.sjs b/browser/components/search/test/browser/mozsearch.sjs
new file mode 100644
index 0000000000..bde867c93e
--- /dev/null
+++ b/browser/components/search/test/browser/mozsearch.sjs
@@ -0,0 +1,11 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+ resp.setHeader("Content-Type", "text/html", false);
+ if (req.hasHeader("Origin") && req.getHeader("Origin") != "null") {
+ resp.write("error");
+ return;
+ }
+ resp.write("hello world");
+}
diff --git a/browser/components/search/test/browser/opensearch.html b/browser/components/search/test/browser/opensearch.html
new file mode 100644
index 0000000000..00620e3bcc
--- /dev/null
+++ b/browser/components/search/test/browser/opensearch.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<link rel="search" type="application/opensearchdescription+xml" title="engine1" href="http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine.xml">
+<link rel="search" type="application/opensearchdescription+xml" title="engine2" href="http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine_mozsearch.xml">
+<link rel="search" type="application/opensearchdescription+xml" title="engineInvalid" href="http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine_404.xml">
+</head>
+<body></body>
+</html>
diff --git a/browser/components/search/test/browser/redirect_ad.sjs b/browser/components/search/test/browser/redirect_ad.sjs
new file mode 100644
index 0000000000..36be567d3f
--- /dev/null
+++ b/browser/components/search/test/browser/redirect_ad.sjs
@@ -0,0 +1,10 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "redirect_final.sjs", false);
+ response.setHeader("Cache-Control", "no-cache, must-revalidate", false);
+}
diff --git a/browser/components/search/test/browser/redirect_final.sjs b/browser/components/search/test/browser/redirect_final.sjs
new file mode 100644
index 0000000000..14debde6ba
--- /dev/null
+++ b/browser/components/search/test/browser/redirect_final.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "https://example.com/hello_world", false);
+}
diff --git a/browser/components/search/test/browser/redirect_once.sjs b/browser/components/search/test/browser/redirect_once.sjs
new file mode 100644
index 0000000000..d15f3afe6d
--- /dev/null
+++ b/browser/components/search/test/browser/redirect_once.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "redirect_final.sjs", false);
+}
diff --git a/browser/components/search/test/browser/redirect_thrice.sjs b/browser/components/search/test/browser/redirect_thrice.sjs
new file mode 100644
index 0000000000..b7c7069162
--- /dev/null
+++ b/browser/components/search/test/browser/redirect_thrice.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "redirect_twice.sjs", false);
+}
diff --git a/browser/components/search/test/browser/redirect_twice.sjs b/browser/components/search/test/browser/redirect_twice.sjs
new file mode 100644
index 0000000000..099d20022e
--- /dev/null
+++ b/browser/components/search/test/browser/redirect_twice.sjs
@@ -0,0 +1,9 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader("Location", "redirect_once.sjs", false);
+}
diff --git a/browser/components/search/test/browser/search-engines/basic/manifest.json b/browser/components/search/test/browser/search-engines/basic/manifest.json
new file mode 100644
index 0000000000..3bdb68fea1
--- /dev/null
+++ b/browser/components/search/test/browser/search-engines/basic/manifest.json
@@ -0,0 +1,20 @@
+{
+ "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",
+ "keyword": "@basic",
+ "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1",
+ "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}"
+ }
+ }
+}
diff --git a/browser/components/search/test/browser/search-engines/private/manifest.json b/browser/components/search/test/browser/search-engines/private/manifest.json
new file mode 100644
index 0000000000..69ef8b29ef
--- /dev/null
+++ b/browser/components/search/test/browser/search-engines/private/manifest.json
@@ -0,0 +1,20 @@
+{
+ "name": "private",
+ "manifest_version": 2,
+ "version": "1.0",
+ "description": "A test private engine",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "private@search.mozilla.org"
+ }
+ },
+ "hidden": true,
+ "chrome_settings_overrides": {
+ "search_provider": {
+ "name": "private",
+ "keyword": "@private",
+ "search_url": "https://example.com",
+ "suggest_url": "https://example.com?search={searchTerms}"
+ }
+ }
+}
diff --git a/browser/components/search/test/browser/searchSuggestionEngine.sjs b/browser/components/search/test/browser/searchSuggestionEngine.sjs
new file mode 100644
index 0000000000..515b56984a
--- /dev/null
+++ b/browser/components/search/test/browser/searchSuggestionEngine.sjs
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.importGlobalProperties(["TextEncoder"]);
+
+let gTimer;
+
+function handleRequest(req, resp) {
+ // Parse the query params. If the params aren't in the form "foo=bar", then
+ // treat the entire query string as a search string.
+ let params = req.queryString.split("&").reduce((memo, pair) => {
+ let [key, val] = pair.split("=");
+ if (!val) {
+ // This part isn't in the form "foo=bar". Treat it as the search string
+ // (the "query").
+ val = key;
+ key = "query";
+ }
+ memo[decode(key)] = decode(val);
+ return memo;
+ }, {});
+
+ let timeout = parseInt(params.timeout);
+ if (timeout) {
+ // Write the response after a timeout.
+ resp.processAsync();
+ gTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ gTimer.init(
+ () => {
+ writeResponse(params, resp);
+ resp.finish();
+ },
+ timeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ return;
+ }
+
+ writeResponse(params, resp);
+}
+
+function writeResponse(params, resp) {
+ // Echo back the search string with "foo" and "bar" appended.
+ let suffixes = ["foo", "bar"];
+ let data = [params.query, suffixes.map(s => params.query + s)];
+ resp.setHeader("Content-Type", "application/json", false);
+
+ let json = JSON.stringify(data);
+ let utf8 = String.fromCharCode(...new TextEncoder().encode(json));
+ resp.write(utf8);
+}
+
+function decode(str) {
+ return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" ")));
+}
diff --git a/browser/components/search/test/browser/searchTelemetry.html b/browser/components/search/test/browser/searchTelemetry.html
new file mode 100644
index 0000000000..bd395d4a7c
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetry.html
@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <a href="https://example.com/otherpage">Non ad link</a>
+ <a href="https://example1.com/ad">Matching path prefix, different server</a>
+ <a href="https://mochi.test:8888/otherpage">Non ad link</a>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd.html b/browser/components/search/test/browser/searchTelemetryAd.html
new file mode 100644
index 0000000000..23d51d2fb5
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <a id="ad1" href="https://example.com/ad">Ad link</a>
+ <a id="ad2" href="https://example.com/ad2">Second Ad link</a>
+ <!-- The iframe is used to include a sub-document load in the test, which
+ should not affect the recorded telemetry. -->
+ <iframe src="searchTelemetry.html"></iframe>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel.html
new file mode 100644
index 0000000000..71049be20c
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel.html
@@ -0,0 +1,116 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section id="top">
+ <!--
+ Carousels can have multiple hidden links.
+ -->
+ <h5 test-label="true">ad_carousel</h5>
+ <div class="moz-carousel" narrow="true">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <button type="button">Next</button>
+ </div>
+ <!--
+ Carousels can be used for non-ads.
+ -->
+ <h5 test-label="true">non_ad_carousel</h5>
+ <div class="moz-carousel" narrow="true">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Giraffes</h3>
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Rhinos</h3>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_below_the_fold.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_below_the_fold.html
new file mode 100644
index 0000000000..737e1e654b
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_below_the_fold.html
@@ -0,0 +1,83 @@
+<!--
+ This is for testing a carousel below the fold.
+-->
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section id="top" style="padding-top: 1000px;">
+ <h5 test-label="true">ad_carousel</h5>
+ <div class="moz-carousel-container">
+ <div class="moz-carousel" narrow="true">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_doubled.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_doubled.html
new file mode 100644
index 0000000000..f7b7f948d9
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_doubled.html
@@ -0,0 +1,182 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section id="top">
+ <!--
+ Carousels can have multiple hidden links.
+ -->
+ <h5 test-label="true">ad_carousel</h5>
+ <div class="moz-carousel" narrow="true">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ </div>
+ <h5 test-label="true">ad_carousel</h5>
+ <div class="moz-carousel" narrow="true" id="second-ad">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ </div>
+ <!--
+ Carousels can be used for non-ads.
+ -->
+ <h5 test-label="true">non_ad_carousel</h5>
+ <div class="moz-carousel" narrow="true">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Giraffes</h3>
+ </a>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/some-normal-path"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Rhinos</h3>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_first_element_non_visible.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_first_element_non_visible.html
new file mode 100644
index 0000000000..b5a44b325e
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_first_element_non_visible.html
@@ -0,0 +1,85 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section id="top">
+ <!--
+ If a user scrolls a carousel before the impression is snapped,
+ we shouldn't count elements that aren't fully shown in the carousel
+ as visible.
+ -->
+ <h5 test-label="true">ad_carousel</h5>
+ <div class="moz-carousel-container">
+ <div class="moz-carousel" narrow="true">
+ <div style="margin-left: -80px;" class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_hidden.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_hidden.html
new file mode 100644
index 0000000000..cccd714326
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_hidden.html
@@ -0,0 +1,87 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css"
+ href="./serp.css" />
+</head>
+<body>
+ <section id="top">
+ <h5 test-label="true">ad_carousel with display: none;</h5>
+ <div class="moz-carousel" narrow="true" style="display: none;">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h5 test-label="true">ad_carousel with no width;</h5>
+ <div class="moz-carousel" narrow="true" style="width: 0;">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h5 test-label="true">ad_carousel with no height;</h5>
+ <div class="moz-carousel" narrow="true" style="height: 0;">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <h5 test-label="true">ad_carousel that is far above the page</h5>
+ <div class="moz-carousel" narrow="true" style="position: absolute; top: -9999px;">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_outer_container.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_outer_container.html
new file mode 100644
index 0000000000..759bd9f0d9
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_outer_container.html
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section id="top">
+ <!--
+ Carousels can sometimes have an outer container that doesn't always show up.
+ -->
+ <h5 test-label="true">ad_carousel</h5>
+ <div class="moz-carousel-container">
+ <div class="moz-carousel" extra="true">
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ <div class="moz-carousel-card">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/some-normal-path">
+ <div class="moz-carousel-image">Image</div>
+ </a>
+ <div class="moz-carousel-card-inner">
+ <div class="moz-carousel-card-inner-content">
+ <a class="hidden" href="https://example.com/ad"></a>
+ <a href="https://example.com/normal-path">
+ <h3>Name of Product</h3>
+ </a>
+ <h3>$199.99</h3>
+ <h3>Example.com</h3>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_text.html b/browser/components/search/test/browser/searchTelemetryAd_components_text.html
new file mode 100644
index 0000000000..bc1219bfa9
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_text.html
@@ -0,0 +1,112 @@
+<!--
+ Text ads reuse the data-ad element in multiple components to make it
+ difficult to determine which component it belongs to, similar to Bing.
+-->
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section id="searchresults">
+ <div class="lhs">
+ <div class="moz_ad">
+ <h5 test-label>ad_sitelink</h5>
+ <a href="https://example.com/ad/1">
+ <h2>Example Result</h2>
+ </a>
+ <span><a href="https://example.com/ad/2">Ad link that says there are 10 Locations nearby</a></span>
+ <div class="multi-col">
+ <div>
+ <a href="https://example.com/ad/3">
+ <h2>New Releases</h2>
+ </a>
+ <span>Cras ac velit sed tellus</span>
+ </div>
+ <div>
+ <a id="deep_ad_sitelink" href="https://example.com/ad/4">
+ <h2>Men's</h2>
+ </a>
+ <span>Cras ac velit sed tellus</span>
+ </div>
+ <div>
+ <a href="https://example.com/ad/5">
+ <h2>Women's</h2>
+ </a>
+ <span>Cras ac velit sed tellus</span>
+ </div>
+ <div>
+ <!-- Ensure ads encoded in data-attributes are also recorded properly -->
+ <a data-moz-attr="https://example.com/ad/6" href="https://example.com/normal-link">
+ <h2>Sale</h2>
+ </a>
+ <span>Cras ac velit sed tellus</span>
+ </div>
+ </div>
+ </div>
+ <div class="moz_ad">
+ <h5 test-label>ad_link</h5>
+ <a id="ad_link_redirect" href="https://example.org/browser/browser/components/search/test/browser/redirect_ad.sjs">
+ <h2>Example Shop</h2>
+ </a>
+ <div class="factrow">
+ <a href="https://example.com/ad/8">Home Page</a>
+ <a href="https://example.com/ad/9">Products</a>
+ <a href="https://example.com/ad/10">Sales</a>
+ </div>
+ </div>
+ <div class="moz_ad">
+ <h5 test-label>ad_link</h5>
+ <a href="https://example.com/ad/11">
+ <h2>Example Shop</h2>
+ </a>
+ </div>
+ <div>
+ <h5 test-label>non_ads_link</h5>
+ <a id="non_ads_link" href="https://example.com/browser/browser/components/search/test/browser/cacheable.html">
+ Example of a cached non ad
+ </a><br />
+ <a id="non_ads_link_redirected" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html">
+ Example of a redirected non ad link
+ </a><br />
+ <a id="non_ads_link_redirected_no_top_level" href="#">
+ Example of a redirected non ad link that isn't initially top level loaded
+ </a><br />
+ <a id="non_ads_link_multiple_redirects" href="https://example.com/browser/browser/components/search/test/browser/redirect_thrice.sjs">
+ Example of a redirected non ad link that's redirected multiple times
+ </a><br />
+ <a id="non_ads_link_with_special_characters_in_path" href="https://example.com/path'?hello_world&foo=bar's">
+ Example of a non ad with special characters in path
+ </a>
+ </div>
+ </div>
+ <div class="rhs">
+ <h5 test-label>ad_sidebar</h5>
+ <div class="moz_ad">
+ <a href="https://example.com/ad/15">
+ <div class="mock-image">Mock ad image</div>
+ </a>
+ <a href="https://example.com/ad/16">
+ <h3>Buy Example Now</h3>
+ </a>
+ <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</p>
+ <a href="https://example.com/ad/17">Buy Now</a>
+ </div>
+ </div>
+ </section>
+ <iframe style="display: none;"></iframe>
+ <script>
+ window.addEventListener("message", (event) => {
+ if (event.origin == "https://example.org") {
+ window.location.href = event.data;
+ }
+ });
+ document.getElementById("non_ads_link_redirected_no_top_level")
+ .addEventListener("click", (event) => {
+ event.preventDefault();
+ let iframe = document.querySelector("iframe");
+ iframe.src = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html";
+ });
+ </script>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_visibility.html b/browser/components/search/test/browser/searchTelemetryAd_components_visibility.html
new file mode 100644
index 0000000000..475ada3a3c
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_components_visibility.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section id="top">
+ <div style="display: flex; gap: 20px;">
+ <div>
+ <h5 test-label="true">ad_link</h5>
+ <!-- The parent size exceeds the window height but the first ad link is above the fold. -->
+ <div class="moz_ad" style="padding-bottom: 2000px;">
+ <a href="https://example.com/ad">Ad Link</a>
+ </div>
+ </div>
+ <div>
+ <h5 test-label="true" >ad_link</h5>
+ <a href="https://example.com/ad">Ad Link</a>
+ </div>
+ <!-- The ad links are below the fold but the test will scroll to it before the impression is recorded. -->
+ <div>
+ <h5 test-label="true">ad_link</h5>
+ <div id="second-ad" class="moz_ad" style="padding-top: 2000px;">
+ <a href="https://example.com/ad">Ad Link</a>
+ </div>
+ </div>
+ <div>
+ <h5 test-label="true" style="margin-bottom: 2000px;">ad_link</h5>
+ <a href="https://example.com/ad">Ad Link</a>
+ </div>
+ <!-- The ad links are below the fold and shouldn't be viewed in the test. -->
+ <div>
+ <h5 test-label="true">ad_link</h5>
+ <div class="moz_ad" style="padding-top: 4000px;">
+ <a href="https://example.com/ad">Ad Link</a>
+ </div>
+ </div>
+ <div>
+ <h5 test-label="true" style="margin-bottom: 4000px;">ad_link</h5>
+ <a href="https://example.com/ad">Ad Link</a>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_dataAttributes.html b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes.html
new file mode 100644
index 0000000000..7bc1b2745e
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes.html
@@ -0,0 +1,10 @@
+<!-- This HTML file encodes the ad link in the data attribute -->
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <a data-xyz="https://example.com/ad123" href="https://example.com/otherpage">Ad link</a>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_href.html b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_href.html
new file mode 100644
index 0000000000..319485cfae
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_href.html
@@ -0,0 +1,10 @@
+<!-- This HTML file encodes the ad link in the href attribute and has irrelevant data in data attribute -->
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <a data-xyz="https://example.com/otherpage" href="https://example.com/ad123">Ad link</a>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_none.html b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_none.html
new file mode 100644
index 0000000000..a119cf71be
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_none.html
@@ -0,0 +1,10 @@
+<!-- This HTML file has non-ad data in both the href and data attribute -->
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <a data-xyz="https://example.com/otherpage" href="https://example.com/otherpage">Non-Ad Link</a>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html
new file mode 100644
index 0000000000..d987356d7e
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Page will do a redirect</title>
+ <meta content="0;url=https://example.com/hello_world" http-equiv="refresh">
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html^headers^
new file mode 100644
index 0000000000..94cde2a288
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html^headers^
@@ -0,0 +1 @@
+Cache-Control: no-cache, must-revalidate
diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html
new file mode 100644
index 0000000000..1c5c31cb38
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Page will do a redirect without doing it in a top load</title>
+ <!-- <meta content="0;url=https://example.com/hello_world" http-equiv="refresh"> -->
+ <script>
+ let parentWindow = window.parent;
+ let url = "https://example.com/hello_world";
+ parentWindow.postMessage(url, "*");
+ </script>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^
new file mode 100644
index 0000000000..419697b050
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^
@@ -0,0 +1,4 @@
+Cache-Control: no-cache, must-revalidate
+Pragma: no-cache
+Expires: Fri, 01 Jan 1990 00:00:00 GMT
+Content-Type: text/html; charset=ISO-8859-1
diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox.html b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html
new file mode 100644
index 0000000000..ca38c13218
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section>
+ <form role="search">
+ <input type="text" value="test" />
+ <div>
+ <ul>
+ <li id="suggest">test</li>
+ </div>
+ </form>
+ </section>
+ <section id="searchresults">
+ <div class="lhs">
+ <div>
+ <h5 test-label>non_ads_link</h5>
+ <a id="non_ads_link" href="https://example.com/hello_world">
+ <h2>Example of a non ad</h2>
+ </a>
+ </div>
+ </div>
+ </section>
+</body>
+<script type="text/javascript">
+ document.querySelector("form").addEventListener("submit", event => {
+ event.preventDefault();
+ window.location.href = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox.html?s=test&abc=ff";
+ })
+ document.getElementById("suggest").addEventListener("click", event => {
+ event.preventDefault();
+ window.location.href = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox.html?s=test&abc=ff";
+ })
+</script>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html^headers^
new file mode 100644
index 0000000000..62847d0585
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html^headers^
@@ -0,0 +1 @@
+Cache-Control: private, max-age=0
diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html
new file mode 100644
index 0000000000..e381135561
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <link rel="stylesheet" type="text/css" href="./serp.css" />
+</head>
+<body>
+ <section>
+ <form role="search">
+ <input type="text" value="test" />
+ </form>
+ </section>
+ <nav>
+ <a id="images" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test&page=images">Images</a>
+ <a id="shopping" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test&page=shopping">Shopping</a>
+ <a id="extra" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox.html?s=test">Extra Page</a>
+ </nav>
+ <section class="refined-search-buttons">
+ <a id="refined-search-button" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test's">Test's</a>
+ <a id="refined-search-button-with-partner-code" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test2&abc=ff">Test 2</a>
+ </section>
+ <section id="searchresults">
+ <div class="lhs">
+ <div>
+ <h2>Related Searches</h2>
+ <a id="related-new-tab" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three" target="_blank">test one two three</a>
+ <a id="related-redirect" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html" target="_blank">test one two three</a>
+ <a id="related-in-page" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three">test one two three</a>
+ </div>
+ </div>
+ </section>
+</body>
+<script type="text/javascript">
+ document.querySelector("form").addEventListener("submit", event => {
+ event.preventDefault();
+ window.location.href = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test&abc=ff";
+ });
+</script>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html^headers^
new file mode 100644
index 0000000000..94cde2a288
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html^headers^
@@ -0,0 +1 @@
+Cache-Control: no-cache, must-revalidate
diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html
new file mode 100644
index 0000000000..901dd54a55
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Page will do a redirect</title>
+ <meta content="0;url=https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three" http-equiv="refresh">
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html^headers^
new file mode 100644
index 0000000000..94cde2a288
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html^headers^
@@ -0,0 +1 @@
+Cache-Control: no-cache, must-revalidate
diff --git a/browser/components/search/test/browser/searchTelemetryAd_shopping.html b/browser/components/search/test/browser/searchTelemetryAd_shopping.html
new file mode 100644
index 0000000000..faa6c057a4
--- /dev/null
+++ b/browser/components/search/test/browser/searchTelemetryAd_shopping.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Document</title>
+</head>
+<body>
+ <nav>
+ <a href="https://example.org/search?q=something&page=images&foo=bar">Images</a>
+ <a id="shopping" href="https://example.org/search?q=something&page=shopping&foo=bar">Shopping</a>
+ </nav>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/serp.css b/browser/components/search/test/browser/serp.css
new file mode 100644
index 0000000000..5b3865da44
--- /dev/null
+++ b/browser/components/search/test/browser/serp.css
@@ -0,0 +1,164 @@
+:root {
+ --margin-left: 80px;
+ --subtle: whitesmoke;
+ --carousel-card-width: 180px;
+}
+
+body {
+ margin: 0;
+ padding: 0 0 80px 0;
+}
+
+a:link {
+ text-decoration: none;
+}
+
+a:visited {
+ color: blue;
+}
+
+h5[test-label] {
+ margin-top: 30px;
+ margin-bottom: 4px;
+}
+
+nav {
+ border-bottom: 1px solid #ececec;
+ padding-bottom: 20px;
+ margin-bottom: 20px;
+}
+
+#searchform {
+ padding-top: 20px;
+ margin-bottom: 20px;
+}
+
+nav>div,
+#searchform,
+.moz-carousel,
+.factrow {
+ display: flex;
+ align-items: center;
+}
+
+nav>div,
+#searchform {
+ gap: 40px;
+}
+
+nav>div,
+#searchform,
+#searchresults,
+#top {
+ margin-left: var(--margin-left);
+}
+
+#searchbox {
+ font-size: 14px;
+ padding: 10px 20px;
+ width: 300px;
+ border-radius: 20px;
+ border: 2px solid var(--subtle);
+ height: 20px;
+}
+
+.card-container {
+ white-space: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+}
+
+.card-container>.card {
+ height: 160px;
+ border-radius: 3px;
+ border: 1px solid var(--subtle);
+ display: inline-block;
+ box-sizing: border-box;
+ padding: 10px;
+}
+
+.card-container>.card:not(:last-child) {
+ margin-right: 10px;
+}
+
+.card-container>.card>a {
+ display: block;
+ margin-bottom: 2px;
+}
+
+#searchresults {
+ width: 900px;
+ display: grid;
+ grid-template-columns: 600px 300px;
+}
+
+.moz-carousel,
+.factrow {
+ gap: 10px;
+}
+
+.moz-carousel {
+ overflow: hidden;
+}
+
+.moz-carousel[narrow],
+.moz-carousel-container {
+ width: calc(var(--carousel-card-width) * 3 + (3 * 10px));
+ overflow-x: auto;
+}
+
+.moz-carousel[extra] {
+ width: calc(var(--carousel-card-width) * 4 + (3 * 10px));
+}
+
+.moz-carousel>.moz-inner {
+ border: 1px solid var(--subtle);
+ border-radius: 10px;
+ padding: 10px;
+}
+
+.moz-carousel>.moz-carousel-card {
+ flex: 1 0 var(--carousel-card-width);
+ border: 1px solid var(--subtle);
+ font-size: 14px;
+}
+
+.moz-carousel-card .moz-carousel-image {
+ width: 100%;
+ height: 120px;
+ background-color: var(--subtle);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.moz-carousel-card-inner-content {
+ padding: 10px 20px 20px 20px;
+}
+
+.multi-col {
+ display: grid;
+ padding: 10px 20px 20px 20px;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.mock-image {
+ height: 100px;
+ background-color: var(--subtle);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Some SERPs hide anchors using CSS */
+.hidden {
+ display: none;
+}
+
+/* Typography */
+h2 {
+ line-height: 100%;
+ margin-bottom: 10px;
+ margin-top: 10px;
+}
diff --git a/browser/components/search/test/browser/slow_loading_page_with_ads.html b/browser/components/search/test/browser/slow_loading_page_with_ads.html
new file mode 100644
index 0000000000..35ac9878ec
--- /dev/null
+++ b/browser/components/search/test/browser/slow_loading_page_with_ads.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+ <a id="ad1" href="https://example.com/ad">Ad link</a>
+ <a id="ad2" href="https://example.com/ad2">Second Ad link</a>
+ <!-- The iframe is used to include a sub-document load in the test, which
+ should not affect the recorded telemetry. -->
+ <iframe src="searchTelemetry.html"></iframe>
+ <img src="https://example.org/browser/browser/components/search/test/browser/slow_loading_page_with_ads.sjs">
+</body>
+</html>
diff --git a/browser/components/search/test/browser/slow_loading_page_with_ads.sjs b/browser/components/search/test/browser/slow_loading_page_with_ads.sjs
new file mode 100644
index 0000000000..7a6382d1cb
--- /dev/null
+++ b/browser/components/search/test/browser/slow_loading_page_with_ads.sjs
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(request, response) {
+ const DELAY_MS = 2000;
+ response.processAsync();
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "image/png", false);
+ response.write("Start loading image");
+
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ () => {
+ response.write("Finish loading image");
+ response.finish();
+ },
+ DELAY_MS,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+}
diff --git a/browser/components/search/test/browser/slow_loading_page_with_ads_on_load_event.html b/browser/components/search/test/browser/slow_loading_page_with_ads_on_load_event.html
new file mode 100644
index 0000000000..307b24d4fe
--- /dev/null
+++ b/browser/components/search/test/browser/slow_loading_page_with_ads_on_load_event.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body id='body'>
+</body>
+ <img src="https://example.org/browser/browser/components/search/test/browser/slow_loading_page_with_ads.sjs">
+ <script>
+ setTimeout(() => {
+ let body = document.getElementById('body');
+ let ad1 = document.createElement('a');
+ ad1.setAttribute('id', 'ad1');
+ ad1.setAttribute('href', 'https://example.com/ad');
+ ad1.innerHTML = 'Ad link'
+
+ let ad2 = document.createElement('a');
+ ad2.setAttribute('id', 'ad2');
+ ad2.setAttribute('href', 'https://example.com/ad2');
+ ad2.innerHTML = 'Second Ad link'
+
+ let frame = document.createElement('iframe');
+ frame.setAttribute('src', 'searchTelemetry.html');
+
+ body.appendChild(ad1);
+ body.appendChild(ad2);
+ body.appendChild(frame);
+ }, 2000);
+ </script>
+</html>
diff --git a/browser/components/search/test/browser/telemetrySearchSuggestions.sjs b/browser/components/search/test/browser/telemetrySearchSuggestions.sjs
new file mode 100644
index 0000000000..1978b4f665
--- /dev/null
+++ b/browser/components/search/test/browser/telemetrySearchSuggestions.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(req, resp) {
+ let suffixes = ["foo", "bar"];
+ let data = [req.queryString, suffixes.map(s => req.queryString + s)];
+ resp.setHeader("Content-Type", "application/json", false);
+ resp.write(JSON.stringify(data));
+}
diff --git a/browser/components/search/test/browser/telemetrySearchSuggestions.xml b/browser/components/search/test/browser/telemetrySearchSuggestions.xml
new file mode 100644
index 0000000000..057fc70bf5
--- /dev/null
+++ b/browser/components/search/test/browser/telemetrySearchSuggestions.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_UsageTelemetry usageTelemetrySearchSuggestions.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/telemetrySearchSuggestions.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://example.com" rel="searchform"/>
+</SearchPlugin>
diff --git a/browser/components/search/test/browser/test.html b/browser/components/search/test/browser/test.html
new file mode 100644
index 0000000000..a39bece4ff
--- /dev/null
+++ b/browser/components/search/test/browser/test.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title>Bug 426329</title>
+</head>
+<body></body>
+</html>
diff --git a/browser/components/search/test/browser/testEngine.xml b/browser/components/search/test/browser/testEngine.xml
new file mode 100644
index 0000000000..9c25993232
--- /dev/null
+++ b/browser/components/search/test/browser/testEngine.xml
@@ -0,0 +1,12 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
+ xmlns:moz="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>Foo</ShortName>
+ <Description>Foo Search</Description>
+ <InputEncoding>utf-8</InputEncoding>
+ <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search">
+ <Param name="test" value="{searchTerms}"/>
+ </Url>
+ <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm>
+ <moz:Alias>fooalias</moz:Alias>
+</OpenSearchDescription>
diff --git a/browser/components/search/test/browser/testEngine_diacritics.xml b/browser/components/search/test/browser/testEngine_diacritics.xml
new file mode 100644
index 0000000000..340893348d
--- /dev/null
+++ b/browser/components/search/test/browser/testEngine_diacritics.xml
@@ -0,0 +1,12 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
+ xmlns:moz="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>Foo &#9825;</ShortName>
+ <Description>Engine whose ShortName contains non-BMP Unicode characters</Description>
+ <InputEncoding>utf-8</InputEncoding>
+ <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search">
+ <Param name="test" value="{searchTerms}"/>
+ </Url>
+ <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm>
+ <moz:Alias>diacriticalias</moz:Alias>
+</OpenSearchDescription>
diff --git a/browser/components/search/test/browser/testEngine_dupe.xml b/browser/components/search/test/browser/testEngine_dupe.xml
new file mode 100644
index 0000000000..86c4cfadaf
--- /dev/null
+++ b/browser/components/search/test/browser/testEngine_dupe.xml
@@ -0,0 +1,12 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"
+ xmlns:moz="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>FooDupe</ShortName>
+ <Description>Second Engine Search</Description>
+ <InputEncoding>utf-8</InputEncoding>
+ <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search">
+ <Param name="test" value="{searchTerms}"/>
+ </Url>
+ <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm>
+ <moz:Alias>secondalias</moz:Alias>
+</OpenSearchDescription>
diff --git a/browser/components/search/test/browser/testEngine_mozsearch.xml b/browser/components/search/test/browser/testEngine_mozsearch.xml
new file mode 100644
index 0000000000..2f285feb4c
--- /dev/null
+++ b/browser/components/search/test/browser/testEngine_mozsearch.xml
@@ -0,0 +1,14 @@
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+ <ShortName>Foo</ShortName>
+ <Description>Foo Search</Description>
+ <InputEncoding>utf-8</InputEncoding>
+ <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image>
+ <Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?suggestions&amp;locale={moz:locale}&amp;test={searchTerms}"/>
+ <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/">
+ <Param name="test" value="{searchTerms}"/>
+ <Param name="ie" value="utf-8"/>
+ <MozParam name="channel" condition="purpose" purpose="keyword" value="keywordsearch"/>
+ <MozParam name="channel" condition="purpose" purpose="contextmenu" value="contextsearch"/>
+ </Url>
+ <SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</SearchForm>
+</SearchPlugin>
diff --git a/browser/components/search/test/browser/test_search.html b/browser/components/search/test/browser/test_search.html
new file mode 100644
index 0000000000..010d1fdc82
--- /dev/null
+++ b/browser/components/search/test/browser/test_search.html
@@ -0,0 +1 @@
+test%20search
diff --git a/browser/components/search/test/browser/tooManyEnginesOffered.html b/browser/components/search/test/browser/tooManyEnginesOffered.html
new file mode 100644
index 0000000000..64e48d05e9
--- /dev/null
+++ b/browser/components/search/test/browser/tooManyEnginesOffered.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="UTF-8">
+<link rel="search" type="application/opensearchdescription+xml" title="engine1" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine1.xml">
+<link rel="search" type="application/opensearchdescription+xml" title="engine2" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine2.xml">
+<link rel="search" type="application/opensearchdescription+xml" title="engine3" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine3.xml">
+<link rel="search" type="application/opensearchdescription+xml" title="engine4" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine4.xml">
+<link rel="search" type="application/opensearchdescription+xml" title="engine5" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine5.xml">
+<link rel="search" type="application/opensearchdescription+xml" title="engine6" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine6.xml">
+</head>
+<body></body>
+</html>
diff --git a/browser/components/search/test/browser/trendingSuggestionEngine.sjs b/browser/components/search/test/browser/trendingSuggestionEngine.sjs
new file mode 100644
index 0000000000..c568cc223b
--- /dev/null
+++ b/browser/components/search/test/browser/trendingSuggestionEngine.sjs
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.importGlobalProperties(["TextEncoder"]);
+
+let gTimer;
+
+function handleRequest(req, resp) {
+ // Parse the query params. If the params aren't in the form "foo=bar", then
+ // treat the entire query string as a search string.
+ let params = req.queryString.split("&").reduce((memo, pair) => {
+ let [key, val] = pair.split("=");
+ if (!val) {
+ // This part isn't in the form "foo=bar". Treat it as the search string
+ // (the "query").
+ val = key;
+ key = "query";
+ }
+ memo[decode(key)] = decode(val);
+ return memo;
+ }, {});
+
+ writeResponse(params, resp);
+}
+
+function writeResponse(params, resp) {
+ // Echoes back 15 results, query0, query1, query2 etc.
+ let suffixes = [...Array(15).keys()];
+ let query = params.query || "";
+ let data = [query, suffixes.map(s => query + s)];
+ if (params?.richsuggestions) {
+ data.push([]);
+ data.push({
+ "google:suggestdetail": suffixes.map(s => ({
+ a: "Extended title",
+ dc: "#FFFFFF",
+ i: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==",
+ t: "Title",
+ })),
+ });
+ }
+ resp.setHeader("Content-Type", "application/json", false);
+
+ let json = JSON.stringify(data);
+ let utf8 = String.fromCharCode(...new TextEncoder().encode(json));
+ resp.write(utf8);
+}
+
+function decode(str) {
+ return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" ")));
+}
diff --git a/browser/components/search/test/marionette/manifest.ini b/browser/components/search/test/marionette/manifest.ini
new file mode 100644
index 0000000000..3ca0ae0eb5
--- /dev/null
+++ b/browser/components/search/test/marionette/manifest.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+run-if = buildapp == 'browser'
+
+[test_engines_on_restart.py]
diff --git a/browser/components/search/test/marionette/test_engines_on_restart.py b/browser/components/search/test/marionette/test_engines_on_restart.py
new file mode 100644
index 0000000000..d7a0634e75
--- /dev/null
+++ b/browser/components/search/test/marionette/test_engines_on_restart.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# 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 textwrap
+
+from marionette_harness.marionette_test import MarionetteTestCase
+
+
+class TestEnginesOnRestart(MarionetteTestCase):
+ def setUp(self):
+ super(TestEnginesOnRestart, self).setUp()
+ self.marionette.enforce_gecko_prefs(
+ {
+ "browser.search.log": True,
+ }
+ )
+
+ def get_default_search_engine(self):
+ """Retrieve the identifier of the default search engine."""
+
+ script = """\
+ let [resolve] = arguments;
+ let searchService = Components.classes[
+ "@mozilla.org/browser/search-service;1"]
+ .getService(Components.interfaces.nsISearchService);
+ return searchService.init().then(function () {
+ resolve(searchService.defaultEngine.identifier);
+ });
+ """
+
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ return self.marionette.execute_async_script(textwrap.dedent(script))
+
+ def test_engines(self):
+ self.assertTrue(self.get_default_search_engine().startswith("google"))
+ self.marionette.set_pref("intl.locale.requested", "kk_KZ")
+ self.marionette.restart(clean=False, in_app=True)
+ self.assertTrue(self.get_default_search_engine().startswith("google"))
diff --git a/browser/components/search/test/unit/test_search_telemetry_config_validation.js b/browser/components/search/test/unit/test_search_telemetry_config_validation.js
new file mode 100644
index 0000000000..1c243cfc82
--- /dev/null
+++ b/browser/components/search/test/unit/test_search_telemetry_config_validation.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ TELEMETRY_SETTINGS_KEY: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
+});
+
+/**
+ * Checks to see if a value is an object or not.
+ *
+ * @param {*} value
+ * The value to check.
+ * @returns {boolean}
+ */
+function isObject(value) {
+ return value != null && typeof value == "object" && !Array.isArray(value);
+}
+
+/**
+ * This function modifies the schema to prevent allowing additional properties
+ * on objects. This is used to enforce that the schema contains everything that
+ * we deliver via the search configuration.
+ *
+ * These checks are not enabled in-product, as we want to allow older versions
+ * to keep working if we add new properties for whatever reason.
+ *
+ * @param {object} section
+ * The section to check to see if an additionalProperties flag should be added.
+ */
+function disallowAdditionalProperties(section) {
+ // It is generally acceptable for new properties to be added to the
+ // configuration as older builds will ignore them.
+ //
+ // As a result, we only check for new properties on nightly builds, and this
+ // avoids us having to uplift schema changes. This also helps preserve the
+ // schemas as documentation of "what was supported in this version".
+ if (!AppConstants.NIGHTLY_BUILD) {
+ info("Skipping additional properties validation.");
+ return;
+ }
+
+ if (section.type == "object") {
+ section.additionalProperties = false;
+ }
+ for (let value of Object.values(section)) {
+ if (isObject(value)) {
+ disallowAdditionalProperties(value);
+ }
+ }
+}
+
+add_task(async function test_search_config_validates_to_schema() {
+ let schema = await IOUtils.readJSON(
+ PathUtils.join(do_get_cwd().path, "search-telemetry-schema.json")
+ );
+ disallowAdditionalProperties(schema);
+
+ let data = await RemoteSettings(TELEMETRY_SETTINGS_KEY).get();
+
+ let validator = new JsonSchema.Validator(schema);
+
+ for (let entry of data) {
+ // Records in Remote Settings contain additional properties independent of
+ // the schema. Hence, we don't want to validate their presence.
+ delete entry.schema;
+ delete entry.id;
+ delete entry.last_modified;
+ delete entry.filter_expression;
+
+ let result = validator.validate(entry);
+ let message = `Should validate ${entry.telemetryId}`;
+ if (!result.valid) {
+ message += `:\n${JSON.stringify(result.errors, null, 2)}`;
+ }
+ Assert.ok(result.valid, message);
+ }
+});
diff --git a/browser/components/search/test/unit/test_urlTelemetry.js b/browser/components/search/test/unit/test_urlTelemetry.js
new file mode 100644
index 0000000000..bd46f39e5b
--- /dev/null
+++ b/browser/components/search/test/unit/test_urlTelemetry.js
@@ -0,0 +1,310 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs",
+ SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+const TESTS = [
+ {
+ title: "Google search access point",
+ trackingUrl:
+ "https://www.google.com/search?q=test&ie=utf-8&oe=utf-8&client=firefox-b-1-ab",
+ expectedSearchCountEntry: "google:tagged:firefox-b-1-ab",
+ expectedAdKey: "google:tagged",
+ adUrls: [
+ "https://www.googleadservices.com/aclk=foobar",
+ "https://www.googleadservices.com/pagead/aclk=foobar",
+ "https://www.google.com/aclk=foobar",
+ "https://www.google.com/pagead/aclk=foobar",
+ ],
+ nonAdUrls: [
+ "https://www.googleadservices.com/?aclk=foobar",
+ "https://www.googleadservices.com/bar",
+ "https://www.google.com/image",
+ ],
+ },
+ {
+ title: "Google search access point follow-on",
+ trackingUrl:
+ "https://www.google.com/search?client=firefox-b-1-ab&ei=EI_VALUE&q=test2&oq=test2&gs_l=GS_L_VALUE",
+ expectedSearchCountEntry: "google:tagged-follow-on:firefox-b-1-ab",
+ },
+ {
+ title: "Google organic",
+ trackingUrl:
+ "https://www.google.com/search?client=firefox-b-d-invalid&source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE",
+ expectedSearchCountEntry: "google:organic:other",
+ expectedAdKey: "google:organic",
+ adUrls: ["https://www.googleadservices.com/aclk=foobar"],
+ nonAdUrls: ["https://www.googleadservices.com/?aclk=foobar"],
+ },
+ {
+ title: "Google organic no code",
+ trackingUrl:
+ "https://www.google.com/search?source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE",
+ expectedSearchCountEntry: "google:organic:none",
+ expectedAdKey: "google:organic",
+ adUrls: ["https://www.googleadservices.com/aclk=foobar"],
+ nonAdUrls: ["https://www.googleadservices.com/?aclk=foobar"],
+ },
+ {
+ title: "Google organic UK",
+ trackingUrl:
+ "https://www.google.co.uk/search?source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE",
+ expectedSearchCountEntry: "google:organic:none",
+ },
+ {
+ title: "Bing search access point",
+ trackingUrl: "https://www.bing.com/search?q=test&pc=MOZI&form=MOZLBR",
+ expectedSearchCountEntry: "bing:tagged:MOZI",
+ expectedAdKey: "bing:tagged",
+ adUrls: [
+ "https://www.bing.com/aclick?ld=foo",
+ "https://www.bing.com/aclk?ld=foo",
+ ],
+ nonAdUrls: [
+ "https://www.bing.com/fd/ls/ls.gif?IG=foo",
+ "https://www.bing.com/fd/ls/l?IG=bar",
+ "https://www.bing.com/aclook?",
+ "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=baz&url=%2Fvideos%2Fsearch%3Fq%3Dfoo",
+ "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=bar&url=https%3A%2F%2Fwww.bing.com%2Faclick",
+ "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=bar&url=https%3A%2F%2Fwww.bing.com%2Faclk",
+ ],
+ },
+ {
+ setUp() {
+ Services.cookies.removeAll();
+ Services.cookies.add(
+ "www.bing.com",
+ "/",
+ "SRCHS",
+ "PC=MOZI",
+ false,
+ false,
+ false,
+ Date.now() + 1000 * 60 * 60,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+ },
+ tearDown() {
+ Services.cookies.removeAll();
+ },
+ title: "Bing search access point follow-on",
+ trackingUrl:
+ "https://www.bing.com/search?q=test&qs=n&form=QBRE&sp=-1&pq=&sc=0-0&sk=&cvid=CVID_VALUE",
+ expectedSearchCountEntry: "bing:tagged-follow-on:MOZI",
+ },
+ {
+ title: "Bing organic",
+ trackingUrl: "https://www.bing.com/search?q=test&pc=MOZIfoo&form=MOZLBR",
+ expectedSearchCountEntry: "bing:organic:other",
+ expectedAdKey: "bing:organic",
+ adUrls: ["https://www.bing.com/aclick?ld=foo"],
+ nonAdUrls: ["https://www.bing.com/fd/ls/ls.gif?IG=foo"],
+ },
+ {
+ title: "Bing organic no code",
+ trackingUrl:
+ "https://www.bing.com/search?q=test&qs=n&form=QBLH&sp=-1&pq=&sc=0-0&sk=&cvid=CVID_VALUE",
+ expectedSearchCountEntry: "bing:organic:none",
+ expectedAdKey: "bing:organic",
+ adUrls: ["https://www.bing.com/aclick?ld=foo"],
+ nonAdUrls: ["https://www.bing.com/fd/ls/ls.gif?IG=foo"],
+ },
+ {
+ title: "DuckDuckGo search access point",
+ trackingUrl: "https://duckduckgo.com/?q=test&t=ffab",
+ expectedSearchCountEntry: "duckduckgo:tagged:ffab",
+ expectedAdKey: "duckduckgo:tagged",
+ adUrls: [
+ "https://duckduckgo.com/y.js?ad_provider=foo",
+ "https://duckduckgo.com/y.js?f=bar&ad_provider=foo",
+ "https://www.amazon.co.uk/foo?tag=duckduckgo-ffab-uk-32-xk",
+ ],
+ nonAdUrls: [
+ "https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images",
+ "https://duckduckgo.com/y.js?ifu=foo",
+ "https://improving.duckduckgo.com/t/bar",
+ ],
+ },
+ {
+ title: "DuckDuckGo organic",
+ trackingUrl: "https://duckduckgo.com/?q=test&t=other&ia=news",
+ expectedSearchCountEntry: "duckduckgo:organic:other",
+ expectedAdKey: "duckduckgo:organic",
+ adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"],
+ nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"],
+ },
+ {
+ title: "DuckDuckGo expected organic code",
+ trackingUrl: "https://duckduckgo.com/?q=test&t=h_&ia=news",
+ expectedSearchCountEntry: "duckduckgo:organic:none",
+ expectedAdKey: "duckduckgo:organic",
+ adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"],
+ nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"],
+ },
+ {
+ title: "DuckDuckGo expected organic code 2",
+ trackingUrl: "https://duckduckgo.com/?q=test&t=hz&ia=news",
+ expectedSearchCountEntry: "duckduckgo:organic:none",
+ expectedAdKey: "duckduckgo:organic",
+ adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"],
+ nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"],
+ },
+ {
+ title: "DuckDuckGo organic no code",
+ trackingUrl: "https://duckduckgo.com/?q=test&ia=news",
+ expectedSearchCountEntry: "duckduckgo:organic:none",
+ expectedAdKey: "duckduckgo:organic",
+ adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"],
+ nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"],
+ },
+ {
+ title: "Baidu search access point",
+ trackingUrl: "https://www.baidu.com/baidu?wd=test&tn=monline_7_dg&ie=utf-8",
+ expectedSearchCountEntry: "baidu:tagged:monline_7_dg",
+ expectedAdKey: "baidu:tagged",
+ adUrls: ["https://www.baidu.com/baidu.php?url=encoded"],
+ nonAdUrls: ["https://www.baidu.com/link?url=encoded"],
+ },
+ {
+ title: "Baidu search access point follow-on",
+ trackingUrl:
+ "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&tn=monline_7_dg&wd=test2&oq=test&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn&rsv_enter=1&rsv_sug3=2&rsv_sug2=0&inputT=227&rsv_sug4=397",
+ expectedSearchCountEntry: "baidu:tagged-follow-on:monline_7_dg",
+ },
+ {
+ title: "Baidu organic",
+ trackingUrl:
+ "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&tn=baidu&bar=&wd=test&rn=&oq&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn",
+ expectedSearchCountEntry: "baidu:organic:other",
+ },
+ {
+ title: "Baidu organic no code",
+ trackingUrl:
+ "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&bar=&wd=test&rn=&oq&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn",
+ expectedSearchCountEntry: "baidu:organic:none",
+ },
+ {
+ title: "Ecosia search access point",
+ trackingUrl: "https://www.ecosia.org/search?tt=mzl&q=foo",
+ expectedSearchCountEntry: "ecosia:tagged:mzl",
+ expectedAdKey: "ecosia:tagged",
+ adUrls: ["https://www.bing.com/aclick?ld=foo"],
+ nonAdUrls: [],
+ },
+ {
+ title: "Ecosia organic",
+ trackingUrl: "https://www.ecosia.org/search?method=index&q=foo",
+ expectedSearchCountEntry: "ecosia:organic:none",
+ expectedAdKey: "ecosia:organic",
+ adUrls: ["https://www.bing.com/aclick?ld=foo"],
+ nonAdUrls: [],
+ },
+];
+
+/**
+ * This function is primarily for testing the Ad URL regexps that are triggered
+ * when a URL is clicked on. These regexps are also used for the `with_ads`
+ * probe. However, we test the ad_clicks route as that is easier to hit.
+ *
+ * @param {string} serpUrl
+ * The url to simulate where the page the click came from.
+ * @param {string} adUrl
+ * The ad url to simulate being clicked.
+ * @param {string} [expectedAdKey]
+ * The expected key to be logged for the scalar. Omit if no scalar should be
+ * logged.
+ */
+async function testAdUrlClicked(serpUrl, adUrl, expectedAdKey) {
+ info(`Testing Ad URL: ${adUrl}`);
+ let channel = NetUtil.newChannel({
+ uri: NetUtil.newURI(adUrl),
+ triggeringPrincipal: Services.scriptSecurityManager.createContentPrincipal(
+ NetUtil.newURI(serpUrl),
+ {}
+ ),
+ loadUsingSystemPrincipal: true,
+ });
+ SearchSERPTelemetry._contentHandler.observeActivity(
+ channel,
+ Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION,
+ Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE
+ );
+ // Since the content handler takes a moment to allow the channel information
+ // to settle down, wait the same amount of time here.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ if (!expectedAdKey) {
+ Assert.ok(
+ !("browser.search.adclicks.unknown" in scalars),
+ "Should not have recorded an ad click"
+ );
+ } else {
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "browser.search.adclicks.unknown",
+ expectedAdKey,
+ 1
+ );
+ }
+}
+
+do_get_profile();
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref(SearchUtils.BROWSER_SEARCH_PREF + "log", true);
+ await SearchSERPTelemetry.init();
+ sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true);
+});
+
+add_task(async function test_parsing_search_urls() {
+ for (const test of TESTS) {
+ info(`Running ${test.title}`);
+ if (test.setUp) {
+ test.setUp();
+ }
+ SearchSERPTelemetry.updateTrackingStatus(
+ {
+ getTabBrowser: () => {},
+ },
+ test.trackingUrl
+ );
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "browser.search.content.unknown",
+ test.expectedSearchCountEntry,
+ 1
+ );
+
+ if ("adUrls" in test) {
+ for (const adUrl of test.adUrls) {
+ await testAdUrlClicked(test.trackingUrl, adUrl, test.expectedAdKey);
+ }
+ for (const nonAdUrls of test.nonAdUrls) {
+ await testAdUrlClicked(test.trackingUrl, nonAdUrls);
+ }
+ }
+
+ if (test.tearDown) {
+ test.tearDown();
+ }
+ }
+});
diff --git a/browser/components/search/test/unit/test_urlTelemetry_generic.js b/browser/components/search/test/unit/test_urlTelemetry_generic.js
new file mode 100644
index 0000000000..610dd56e3a
--- /dev/null
+++ b/browser/components/search/test/unit/test_urlTelemetry_generic.js
@@ -0,0 +1,323 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs",
+ SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp: /^https:\/\/www\.example\.com\/search/,
+ queryParamName: "q",
+ codeParamName: "abc",
+ taggedCodes: ["ff", "tb"],
+ expectedOrganicCodes: ["baz"],
+ organicCodes: ["foo"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/www\.example\.com\/ad2/],
+ shoppingTab: {
+ regexp: "&site=shop",
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+ {
+ telemetryId: "example2",
+ searchPageRegexp: /^https:\/\/www\.example2\.com\/search/,
+ queryParamName: "q",
+ codeParamName: "abc",
+ taggedCodes: ["ff", "tb"],
+ expectedOrganicCodes: ["baz"],
+ organicCodes: ["foo"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/www\.example\.com\/ad2/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+const TESTS = [
+ {
+ title: "Tagged search",
+ trackingUrl: "https://www.example.com/search?q=test&abc=ff",
+ expectedSearchCountEntry: "example:tagged:ff",
+ expectedAdKey: "example:tagged",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Tagged search with shopping",
+ trackingUrl: "https://www.example.com/search?q=test&abc=ff&site=shop",
+ expectedSearchCountEntry: "example:tagged:ff",
+ expectedAdKey: "example:tagged",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ is_shopping_page: "true",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Tagged follow-on",
+ trackingUrl: "https://www.example.com/search?q=test&abc=tb&a=next",
+ expectedSearchCountEntry: "example:tagged-follow-on:tb",
+ expectedAdKey: "example:tagged-follow-on",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "tb",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Organic search matched code",
+ trackingUrl: "https://www.example.com/search?q=test&abc=foo",
+ expectedSearchCountEntry: "example:organic:foo",
+ expectedAdKey: "example:organic",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "foo",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Organic search non-matched code",
+ trackingUrl: "https://www.example.com/search?q=test&abc=ff123",
+ expectedSearchCountEntry: "example:organic:other",
+ expectedAdKey: "example:organic",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "other",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Organic search non-matched code 2",
+ trackingUrl: "https://www.example.com/search?q=test&abc=foo123",
+ expectedSearchCountEntry: "example:organic:other",
+ expectedAdKey: "example:organic",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "other",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Organic search expected organic matched code",
+ trackingUrl: "https://www.example.com/search?q=test&abc=baz",
+ expectedSearchCountEntry: "example:organic:none",
+ expectedAdKey: "example:organic",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Organic search no codes",
+ trackingUrl: "https://www.example.com/search?q=test",
+ expectedSearchCountEntry: "example:organic:none",
+ expectedAdKey: "example:organic",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+ {
+ title: "Different engines using the same adUrl",
+ trackingUrl: "https://www.example2.com/search?q=test",
+ expectedSearchCountEntry: "example2:organic:none",
+ expectedAdKey: "example2:organic",
+ adUrls: ["https://www.example.com/ad2"],
+ nonAdUrls: ["https://www.example.com/ad3"],
+ impression: {
+ provider: "example2",
+ tagged: "false",
+ partner_code: "",
+ is_shopping_page: "false",
+ shopping_tab_displayed: "false",
+ source: "unknown",
+ },
+ },
+];
+
+/**
+ * This function is primarily for testing the Ad URL regexps that are triggered
+ * when a URL is clicked on. These regexps are also used for the `withads`
+ * probe. However, we test the adclicks route as that is easier to hit.
+ *
+ * @param {string} serpUrl
+ * The url to simulate where the page the click came from.
+ * @param {string} adUrl
+ * The ad url to simulate being clicked.
+ * @param {string} [expectedAdKey]
+ * The expected key to be logged for the scalar. Omit if no scalar should be
+ * logged.
+ */
+async function testAdUrlClicked(serpUrl, adUrl, expectedAdKey) {
+ info(`Testing Ad URL: ${adUrl}`);
+ let channel = NetUtil.newChannel({
+ uri: NetUtil.newURI(adUrl),
+ triggeringPrincipal: Services.scriptSecurityManager.createContentPrincipal(
+ NetUtil.newURI(serpUrl),
+ {}
+ ),
+ loadUsingSystemPrincipal: true,
+ });
+ SearchSERPTelemetry._contentHandler.observeActivity(
+ channel,
+ Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION,
+ Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE
+ );
+ // Since the content handler takes a moment to allow the channel information
+ // to settle down, wait the same amount of time here.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ if (!expectedAdKey) {
+ Assert.ok(
+ !("browser.search.adclicks.unknown" in scalars),
+ "Should not have recorded an ad click"
+ );
+ } else {
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "browser.search.adclicks.unknown",
+ expectedAdKey,
+ 1
+ );
+ }
+}
+
+do_get_profile();
+
+add_task(async function setup() {
+ Services.prefs.setBoolPref(SearchUtils.BROWSER_SEARCH_PREF + "log", true);
+ Services.prefs.setBoolPref(
+ SearchUtils.BROWSER_SEARCH_PREF + "serpEventTelemetry.enabled",
+ true
+ );
+ Services.fog.initializeFOG();
+ await SearchSERPTelemetry.init();
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true);
+});
+
+add_task(async function test_parsing_search_urls() {
+ for (const test of TESTS) {
+ info(`Running ${test.title}`);
+ if (test.setUp) {
+ test.setUp();
+ }
+ let browser = {
+ getTabBrowser: () => {},
+ };
+ SearchSERPTelemetry.updateTrackingStatus(browser, test.trackingUrl);
+ SearchSERPTelemetry.reportPageImpression(
+ {
+ url: test.trackingUrl,
+ shoppingTabDisplayed: false,
+ },
+ browser
+ );
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true);
+ TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ "browser.search.content.unknown",
+ test.expectedSearchCountEntry,
+ 1
+ );
+
+ if ("adUrls" in test) {
+ for (const adUrl of test.adUrls) {
+ await testAdUrlClicked(test.trackingUrl, adUrl, test.expectedAdKey);
+ }
+ for (const nonAdUrls of test.nonAdUrls) {
+ await testAdUrlClicked(test.trackingUrl, nonAdUrls);
+ }
+ }
+
+ let recordedEvents = Glean.serp.impression.testGetValue();
+
+ Assert.equal(
+ recordedEvents.length,
+ 1,
+ "should only see one impression event"
+ );
+
+ // To allow deep equality.
+ test.impression.impression_id = recordedEvents[0].extra.impression_id;
+ Assert.deepEqual(recordedEvents[0].extra, test.impression);
+
+ if (test.tearDown) {
+ test.tearDown();
+ }
+
+ // We need to clear Glean events so they don't accumulate for each iteration.
+ Services.fog.testResetFOG();
+ }
+});
diff --git a/browser/components/search/test/unit/xpcshell.ini b/browser/components/search/test/unit/xpcshell.ini
new file mode 100644
index 0000000000..7feeb6d38c
--- /dev/null
+++ b/browser/components/search/test/unit/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+skip-if = toolkit == 'android' # bug 1730213
+firefox-appdir = browser
+
+[test_search_telemetry_config_validation.js]
+support-files =
+ ../../schema/search-telemetry-schema.json
+[test_urlTelemetry.js]
+[test_urlTelemetry_generic.js]