summaryrefslogtreecommitdiffstats
path: root/browser/components/search/test/browser/telemetry
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/search/test/browser/telemetry')
-rw-r--r--browser/components/search/test/browser/telemetry/browser.toml197
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js186
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js167
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js294
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_aboutHome.js135
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js502
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js83
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_content.js204
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js190
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js313
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js263
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js120
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js225
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js287
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js202
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js201
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js218
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js633
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js206
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js146
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js387
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js372
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js457
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js350
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js135
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js329
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js442
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js143
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js349
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js225
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js378
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js373
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js173
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js142
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js506
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js684
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js219
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js524
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js529
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js875
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js661
-rw-r--r--browser/components/search/test/browser/telemetry/cacheable.html12
-rw-r--r--browser/components/search/test/browser/telemetry/cacheable.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/domain_category_mappings.json8
-rw-r--r--browser/components/search/test/browser/telemetry/head-spa.js259
-rw-r--r--browser/components/search/test/browser/telemetry/head.js621
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_ad.sjs10
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_final.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_once.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_thrice.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_twice.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetry.html11
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd.html13
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel.html116
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html83
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html182
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html85
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html87
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html83
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html36
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html112
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html46
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html10
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html10
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html10
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html12
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html17
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^4
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html38
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html39
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html12
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html15
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html45
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html64
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html45
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html84
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html243
-rw-r--r--browser/components/search/test/browser/telemetry/serp.css164
-rw-r--r--browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html14
-rw-r--r--browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs21
-rw-r--r--browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html30
-rw-r--r--browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml6
87 files changed, 15722 insertions, 0 deletions
diff --git a/browser/components/search/test/browser/telemetry/browser.toml b/browser/components/search/test/browser/telemetry/browser.toml
new file mode 100644
index 0000000000..49d8f256aa
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser.toml
@@ -0,0 +1,197 @@
+[DEFAULT]
+tags = "search-telemetry"
+support-files = ["head.js", "head-spa.js"]
+prefs = ["browser.search.log=true"]
+
+["browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js"]
+support-files = [
+ "domain_category_mappings.json",
+ "searchTelemetryDomainCategorizationReporting.html",
+]
+
+["browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js"]
+support-files = ["searchTelemetryAd.html"]
+
+["browser_search_telemetry_abandonment.js"]
+support-files = [
+ "searchTelemetry.html",
+ "searchTelemetryAd.html",
+ "searchTelemetryAd_components_text.html",
+]
+
+["browser_search_telemetry_aboutHome.js"]
+
+["browser_search_telemetry_adImpression_component.js"]
+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"]
+
+["browser_search_telemetry_domain_categorization_ad_values.js"]
+support-files = ["searchTelemetryDomainCategorizationReporting.html"]
+
+["browser_search_telemetry_domain_categorization_download_timer.js"]
+support-files = ["domain_category_mappings.json"]
+
+["browser_search_telemetry_domain_categorization_extraction.js"]
+support-files = ["searchTelemetryDomainExtraction.html"]
+
+["browser_search_telemetry_domain_categorization_region.js"]
+support-files = ["searchTelemetryDomainCategorizationReporting.html"]
+
+["browser_search_telemetry_domain_categorization_reporting.js"]
+support-files = [
+ "searchTelemetryDomainCategorizationReporting.html",
+ "searchTelemetryDomainCategorizationCapProcessedDomains.html",
+]
+
+["browser_search_telemetry_domain_categorization_reporting_timer.js"]
+support-files = ["searchTelemetryDomainCategorizationReporting.html"]
+
+["browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js"]
+support-files = ["searchTelemetryDomainCategorizationReporting.html"]
+
+["browser_search_telemetry_engagement_cached.js"]
+support-files = [
+ "cacheable.html",
+ "cacheable.html^headers^",
+ "searchTelemetryAd_components_text.html",
+ "serp.css",
+]
+
+["browser_search_telemetry_engagement_cached_serp.js"]
+support-files = [
+ "searchTelemetryAd_searchbox.html",
+ "searchTelemetryAd_searchbox.html^headers^",
+]
+
+["browser_search_telemetry_engagement_content.js"]
+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"]
+support-files = [
+ "searchTelemetryAd_searchbox_with_content.html",
+ "searchTelemetryAd_searchbox_with_content.html^headers^",
+]
+
+["browser_search_telemetry_engagement_non_ad.js"]
+support-files = [
+ "searchTelemetryAd_searchbox_with_content.html",
+ "searchTelemetryAd_searchbox_with_content.html^headers^",
+ "serp.css",
+]
+
+["browser_search_telemetry_engagement_query_params.js"]
+support-files = [
+ "searchTelemetryAd_components_query_parameters.html",
+ "serp.css",
+]
+
+["browser_search_telemetry_engagement_redirect.js"]
+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"]
+support-files = [
+ "searchTelemetryAd_components_text.html",
+ "searchTelemetryAd_searchbox.html",
+ "searchTelemetryAd_searchbox.html^headers^",
+ "serp.css",
+]
+
+["browser_search_telemetry_new_window.js"]
+support-files = ["searchTelemetry.html", "searchTelemetryAd.html"]
+
+["browser_search_telemetry_private.js"]
+support-files = ["searchTelemetry.html", "searchTelemetryAd.html", "serp.css"]
+
+["browser_search_telemetry_remote_settings_sync.js"]
+support-files = ["searchTelemetryAd.html", "serp.css"]
+
+["browser_search_telemetry_searchbar.js"]
+https_first_disabled = true
+support-files = [
+ "telemetrySearchSuggestions.sjs",
+ "telemetrySearchSuggestions.xml",
+]
+
+["browser_search_telemetry_shopping.js"]
+support-files = ["searchTelemetryAd_shopping.html"]
+
+["browser_search_telemetry_sources.js"]
+support-files = ["searchTelemetry.html", "searchTelemetryAd.html"]
+
+["browser_search_telemetry_sources_about.js"]
+support-files = ["searchTelemetry.html", "searchTelemetryAd.html"]
+
+["browser_search_telemetry_sources_ads.js"]
+support-files = ["searchTelemetry.html", "searchTelemetryAd.html"]
+
+["browser_search_telemetry_sources_ads_clicks.js"]
+support-files = ["searchTelemetryAd.html"]
+
+["browser_search_telemetry_sources_ads_data_attributes.js"]
+support-files = [
+ "searchTelemetryAd_dataAttributes.html",
+ "searchTelemetryAd_dataAttributes_href.html",
+ "searchTelemetryAd_dataAttributes_none.html",
+]
+
+["browser_search_telemetry_sources_ads_load_events.js"]
+support-files = [
+ "slow_loading_page_with_ads_on_load_event.html",
+ "slow_loading_page_with_ads.html",
+ "slow_loading_page_with_ads.sjs",
+]
+
+["browser_search_telemetry_sources_in_content.js"]
+support-files = ["searchTelemetryAd_searchbox_with_content.html"]
+
+["browser_search_telemetry_sources_navigation.js"]
+support-files = ["searchTelemetry.html", "searchTelemetryAd.html"]
+
+["browser_search_telemetry_sources_webextension.js"]
+support-files = ["searchTelemetry.html", "searchTelemetryAd.html"]
+
+["browser_search_telemetry_spa_in_content.js"]
+support-files = ["searchTelemetrySinglePageApp.html"]
+skip-if = [
+ "(os == 'linux') && tsan && verify",
+] # Can fail on ad_count visibility
+
+["browser_search_telemetry_spa_multi_provider.js"]
+support-files = ["searchTelemetrySinglePageApp.html"]
+
+["browser_search_telemetry_spa_multi_tab.js"]
+support-files = ["searchTelemetrySinglePageApp.html"]
+
+["browser_search_telemetry_spa_single_tab.js"]
+support-files = ["searchTelemetrySinglePageApp.html"]
diff --git a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js
new file mode 100644
index 0000000000..ed71a7c5ed
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js
@@ -0,0 +1,186 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test to verify we can toggle the Glean SERP event telemetry for SERP
+// categorization feature via a Nimbus variable.
+
+const lazy = {};
+const TELEMETRY_PREF =
+ "browser.search.serpEventTelemetryCategorization.enabled";
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+ SearchSERPDomainToCategoriesMap:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "serpEventsCategorizationEnabled",
+ TELEMETRY_PREF,
+ false
+);
+
+// This is required to trigger and properly categorize a SERP.
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [/^https:\/\/example.com/],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ domainExtraction: {
+ ads: [
+ {
+ selectors: "[data-ad-domain]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "adDomain",
+ },
+ },
+ {
+ selectors: ".ad",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ nonAds: [
+ {
+ selectors: "#results .organic a",
+ method: "href",
+ },
+ ],
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+
+ // Enable local telemetry recording for the duration of the tests.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ await insertRecordIntoCollectionAndSync();
+ // If the categorization preference is enabled, we should also wait for the
+ // sync event to update the domain to categories map.
+ if (lazy.serpEventsCategorizationEnabled) {
+ await waitForDomainToCategoriesUpdate();
+ }
+
+ registerCleanupFunction(async () => {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ await SpecialPowers.popPrefEnv();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_enable_experiment_when_pref_is_not_enabled() {
+ let prefBranch = Services.prefs.getDefaultBranch("");
+ let originalPrefValue = prefBranch.getBoolPref(TELEMETRY_PREF);
+
+ // Ensure the build being tested has the preference value as false.
+ // Changing the preference in the test must be done on the default branch
+ // because in the telemetry code, we're referencing the preference directly
+ // instead of through NimbusFeatures. Enrolling in an experiment will change
+ // the default branch, and not overwrite the user branch.
+ prefBranch.setBoolPref(TELEMETRY_PREF, false);
+
+ Assert.equal(
+ lazy.serpEventsCategorizationEnabled,
+ false,
+ "serpEventsCategorizationEnabled should be false when not enrolled in experiment and the default value is false."
+ );
+
+ await lazy.ExperimentAPI.ready();
+
+ info("Enroll in experiment.");
+ let updateComplete = waitForDomainToCategoriesUpdate();
+
+ let doExperimentCleanup = await lazy.ExperimentFakes.enrollWithFeatureConfig(
+ {
+ featureId: NimbusFeatures.search.featureId,
+ value: {
+ serpEventTelemetryCategorizationEnabled: true,
+ },
+ },
+ { isRollout: true }
+ );
+
+ Assert.equal(
+ lazy.serpEventsCategorizationEnabled,
+ true,
+ "serpEventsCategorizationEnabled should be true when enrolled in experiment."
+ );
+
+ await updateComplete;
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a sample SERP with organic results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+ resetTelemetry();
+
+ info("End experiment.");
+ await doExperimentCleanup();
+
+ Assert.equal(
+ lazy.serpEventsCategorizationEnabled,
+ false,
+ "serpEventsCategorizationEnabled should be false after experiment."
+ );
+
+ Assert.ok(
+ lazy.SearchSERPDomainToCategoriesMap.empty,
+ "Domain to categories map should be empty."
+ );
+
+ info("Load a sample SERP with organic results.");
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ // Wait an arbitrary amount for a possible categorization.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(resolve => setTimeout(resolve, 1500));
+ BrowserTestUtils.removeTab(tab);
+
+ assertCategorizationValues([]);
+
+ // Clean up.
+ prefBranch.setBoolPref(TELEMETRY_PREF, originalPrefValue);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js
new file mode 100644
index 0000000000..096178499b
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test to verify we can toggle the Glean SERP event telemetry feature via a
+// Nimbus variable.
+
+const lazy = {};
+
+const TELEMETRY_PREF = "browser.search.serpEventTelemetry.enabled";
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "serpEventsEnabled",
+ TELEMETRY_PREF,
+ false
+);
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?.html/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+async function verifyEventsRecorded() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ },
+ ]);
+}
+
+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;
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ await SpecialPowers.popPrefEnv();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_enable_experiment_when_pref_is_not_enabled() {
+ let prefBranch = Services.prefs.getDefaultBranch("");
+ let originalPrefValue = prefBranch.getBoolPref(TELEMETRY_PREF);
+
+ // Ensure the build being tested has the preference value as false.
+ // Changing the preference in the test must be done on the default branch
+ // because in the telemetry code, we're referencing the preference directly
+ // instead of through NimbusFeatures. Enrolling in an experiment will change
+ // the default branch, and not overwrite the user branch.
+ prefBranch.setBoolPref(TELEMETRY_PREF, false);
+
+ 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."
+ );
+
+ // Clean up.
+ prefBranch.setBoolPref(TELEMETRY_PREF, originalPrefValue);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js
new file mode 100644
index 0000000000..0c1d8b8234
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js
@@ -0,0 +1,294 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.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")
+ );
+
+ await waitForPageWithAdImpressions();
+
+ BrowserTestUtils.removeTab(tab);
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ 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.startLoadingURIString(otherWindow.gBrowser, serpUrl);
+ await browserLoadedPromise;
+ await waitForPageWithAdImpressions();
+
+ await BrowserTestUtils.closeWindow(otherWindow);
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.WINDOW_CLOSE,
+ },
+ },
+ ]);
+});
+
+add_task(async function test_navigation_via_urlbar() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetry.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser,
+ false,
+ "https://www.example.com/"
+ );
+ BrowserTestUtils.startLoadingURIString(gBrowser, "https://www.example.com");
+ await browserLoadedPromise;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ 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.startLoadingURIString(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.");
+ });
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ 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_click_non_ad() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd_components_text.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non_ads_link",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ 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.startLoadingURIString(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/telemetry/browser_search_telemetry_aboutHome.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_aboutHome.js
new file mode 100644
index 0000000000..9e9af43698
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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.startLoadingURIString(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/telemetry/browser_search_telemetry_adImpression_component.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js
new file mode 100644
index 0000000000..8049406d40
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js
@@ -0,0 +1,502 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+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\/telemetry\/searchTelemetryAd/,
+ queryParamNames: ["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,
+ },
+ ],
+ },
+];
+
+async function promiseResize(width, height) {
+ return TestUtils.waitForCondition(() => {
+ return window.outerWidth === width && window.outerHeight === height;
+ }, "Waiting for window to resize");
+}
+
+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.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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ BrowserTestUtils.startLoadingURIString(
+ tab.linkedBrowser,
+ "https://www.example.com/some_url"
+ );
+ await browserLoadedPromise;
+
+ // Reset telemetry because we care about the telemetry upon going back.
+ resetTelemetry();
+
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "pageshow"
+ );
+ tab.linkedBrowser.goBack();
+ await pageShowPromise;
+
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ 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/telemetry/browser_search_telemetry_categorization_timing.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js
new file mode 100644
index 0000000000..9ecc4e8d92
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js
@@ -0,0 +1,83 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [["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/telemetry/browser_search_telemetry_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_content.js
new file mode 100644
index 0000000000..b17604badd
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/browser_search_telemetry_domain_categorization_ad_values.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js
new file mode 100644
index 0000000000..ce18f64e9f
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js
@@ -0,0 +1,190 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * These tests check the number of ads clicked from a SERP containing a
+ * categorization impression. Existing tests already check for the counting ads
+ * and tracking clicks, and the categorization impression piggybacks off
+ * of it. Hence, this is just mostly a sanity check.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ // The search telemetry entry responsible for targeting the specific results.
+ domainExtraction: {
+ ads: [
+ {
+ selectors: "[data-ad-domain]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "adDomain",
+ },
+ },
+ {
+ selectors: ".ad",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ nonAds: [
+ {
+ selectors: "#results .organic a",
+ method: "href",
+ },
+ ],
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+
+ let promise = waitForDomainToCategoriesUpdate();
+ await insertRecordIntoCollectionAndSync();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await promise;
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_load_serp_and_categorize() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+});
+
+add_task(async function test_load_serp_and_categorize_and_click_organic() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ".organic a",
+ {},
+ tab.linkedBrowser
+ );
+ await promise;
+
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_and_categorize_and_click_sponsored() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a sample SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter("a.ad", {}, tab.linkedBrowser);
+ await promise;
+
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "1",
+ num_ads_visible: "2",
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js
new file mode 100644
index 0000000000..d01141d826
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js
@@ -0,0 +1,313 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * This test ensures we are correctly restarting a download of an attachment
+ * after a failure. We simulate failures by not caching the attachment in
+ * Remote Settings.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPDomainToCategoriesMap:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [/^https:\/\/example.com/],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ // The search telemetry entry responsible for targeting the specific results.
+ domainExtraction: {
+ ads: [
+ {
+ selectors: "[data-ad-domain]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "adDomain",
+ },
+ },
+ {
+ selectors: ".ad",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ nonAds: [
+ {
+ selectors: "#results .organic a",
+ method: "href",
+ },
+ ],
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function waitForDownloadError() {
+ return TestUtils.consoleMessageObserved(msg => {
+ return (
+ typeof msg.wrappedJSObject.arguments?.[0] == "string" &&
+ msg.wrappedJSObject.arguments[0].includes("Could not download file:")
+ );
+ });
+}
+
+const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY);
+const db = client.db;
+
+// Shorten the timer so that tests don't have to wait too long.
+const TIMEOUT_IN_MS = 250;
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+
+ await db.clear();
+
+ // Set the state of the pref to false so that tests toggle the preference,
+ // triggering the map to be updated.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", false]],
+ });
+
+ let defaultDownloadSettings = {
+ ...TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS,
+ };
+
+ // Use a much shorter interval from the default preference that when we
+ // simulate download failures, we don't have to wait long before another
+ // download attempt.
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base = TIMEOUT_IN_MS;
+
+ // Normally we add random time to avoid a failure resulting in everyone
+ // hitting the network at once. For tests, we remove this unless explicitly
+ // testing.
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust = 0;
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust = 0;
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS = {
+ ...defaultDownloadSettings,
+ };
+ });
+});
+
+add_task(async function test_download_after_failure() {
+ // Most cases, we should use a convenience function, but in this case,
+ // we want to explictly "forget" to include an attachment to cause a failure.
+ let { record, attachment } = await mockRecordWithAttachment({
+ id: "example_id",
+ version: 1,
+ filename: "domain_category_mappings.json",
+ });
+ await db.create(record);
+ await db.importChanges({}, Date.now());
+
+ let downloadError = waitForDownloadError();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await downloadError;
+
+ // In between the download failure and one of download retries, cache
+ // the attachment so that the next download attempt will be successful.
+ client.attachments.cacheImpl.set(record.id, attachment);
+ await TestUtils.topicObserved("domain-to-categories-map-update-complete");
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a sample SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_visible: "2",
+ num_ads_clicked: "0",
+ },
+ ]);
+
+ // Clean up.
+ await SpecialPowers.popPrefEnv();
+ await resetCategorizationCollection(record);
+});
+
+add_task(async function test_download_after_multiple_failures() {
+ let { record } = await mockRecordWithAttachment({
+ id: "example_id",
+ version: 1,
+ filename: "domain_category_mappings.json",
+ });
+ await db.create(record);
+ await db.importChanges({}, Date.now());
+
+ let downloadError = waitForDownloadError();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await downloadError;
+
+ // Following an initial download failure, the number of allowable retries
+ // should equal to the maximum number per session.
+ for (
+ let i = 0;
+ i < TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxTriesPerSession;
+ ++i
+ ) {
+ await waitForDownloadError();
+ }
+
+ // To ensure we didn't attempt another download, wait more than what another
+ // download error should take.
+ let consoleObserved = false;
+ let timeout = false;
+ let firstPromise = waitForDownloadError().then(() => {
+ consoleObserved = true;
+ });
+ let secondPromise = new Promise(resolve =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, TIMEOUT_IN_MS + 100)
+ ).then(() => (timeout = true));
+ await Promise.race([firstPromise, secondPromise]);
+ Assert.equal(consoleObserved, false, "Encountered download failure");
+ Assert.equal(timeout, true, "Timeout occured");
+
+ Assert.ok(SearchSERPDomainToCategoriesMap.empty, "Map is empty");
+
+ // Clean up.
+ await SpecialPowers.popPrefEnv();
+ await resetCategorizationCollection(record);
+});
+
+add_task(async function test_cancel_download_timer() {
+ let { record } = await mockRecordWithAttachment({
+ id: "example_id",
+ version: 1,
+ filename: "domain_category_mappings.json",
+ });
+ await db.create(record);
+ await db.importChanges({}, Date.now());
+
+ let downloadError = waitForDownloadError();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await downloadError;
+
+ // Changing the gating preference to false before the map is populated
+ // should cancel the download timer.
+ let observeCancel = TestUtils.consoleMessageObserved(msg => {
+ return (
+ typeof msg.wrappedJSObject.arguments?.[0] == "string" &&
+ msg.wrappedJSObject.arguments[0].includes(
+ "Cancel and nullify download timer."
+ )
+ );
+ });
+ await SpecialPowers.popPrefEnv();
+ await observeCancel;
+
+ // To ensure we don't attempt another download, wait a bit over how long the
+ // the download error should take.
+ let consoleObserved = false;
+ let timeout = false;
+ let firstPromise = waitForDownloadError().then(() => {
+ consoleObserved = true;
+ });
+ let secondPromise = new Promise(resolve =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, TIMEOUT_IN_MS + 100)
+ ).then(() => (timeout = true));
+ await Promise.race([firstPromise, secondPromise]);
+ Assert.equal(consoleObserved, false, "Encountered download failure");
+ Assert.equal(timeout, true, "Timeout occured");
+ Assert.ok(SearchSERPDomainToCategoriesMap.empty, "Map is empty");
+
+ // Clean up.
+ await SpecialPowers.popPrefEnv();
+ await resetCategorizationCollection(record);
+});
+
+add_task(async function test_download_adjust() {
+ // To test that we're actually adding a random delay to the base value,
+ // we set the base number to zero so that the next attempt should be
+ // instant but we'll wait in between 0 and 1000ms and expect the
+ // timer to elapse first.
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base = 0;
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust = 1000;
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust = 1000;
+
+ let { record } = await mockRecordWithAttachment({
+ id: "example_id",
+ version: 1,
+ filename: "domain_category_mappings.json",
+ });
+ await db.create(record);
+ await db.importChanges({}, Date.now());
+
+ let downloadError = waitForDownloadError();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await downloadError;
+
+ // The timer should finish before the next error.
+ let consoleObserved = false;
+ let timeout = false;
+ let firstPromise = waitForDownloadError().then(() => {
+ consoleObserved = true;
+ });
+ let secondPromise = new Promise(resolve =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, 250)
+ ).then(() => (timeout = true));
+ await Promise.race([firstPromise, secondPromise]);
+ Assert.equal(timeout, true, "Timeout occured");
+ Assert.equal(consoleObserved, false, "Encountered download failure");
+
+ await firstPromise;
+ Assert.equal(consoleObserved, true, "Encountered download failure");
+
+ // Clean up.
+ await SpecialPowers.popPrefEnv();
+ await resetCategorizationCollection(record);
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base = TIMEOUT_IN_MS;
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust = 0;
+ TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust = 0;
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js
new file mode 100644
index 0000000000..03ddb75481
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js
@@ -0,0 +1,263 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * This test ensures we are correctly extracting domains from a SERP.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+const TESTS = [
+ {
+ title: "Extract domain from href (absolute URL) - one link.",
+ extractorInfos: [
+ {
+ selectors:
+ '#test1 [data-layout="organic"] a[data-testid="result-title-a"]',
+ method: "href",
+ },
+ ],
+ expectedDomains: ["foobar.com"],
+ },
+ {
+ title: "Extract domain from href (absolute URL) - multiple links.",
+ extractorInfos: [
+ {
+ selectors:
+ '#test2 [data-layout="organic"] a[data-testid="result-title-a"]',
+ method: "href",
+ },
+ ],
+ expectedDomains: ["foo.com", "bar.com", "baz.com", "qux.com"],
+ },
+ {
+ title: "Extract domain from href (relative URL).",
+ extractorInfos: [
+ {
+ selectors:
+ '#test3 [data-layout="organic"] a[data-testid="result-title-a"]',
+ method: "href",
+ },
+ ],
+ expectedDomains: ["example.org"],
+ },
+ {
+ title: "Extract domain from data attribute - one link.",
+ extractorInfos: [
+ {
+ selectors: "#test4 [data-dtld]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "dtld",
+ },
+ },
+ ],
+ expectedDomains: ["www.abc.com"],
+ },
+ {
+ title: "Extract domain from data attribute - multiple links.",
+ extractorInfos: [
+ {
+ selectors: "#test5 [data-dtld]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "dtld",
+ },
+ },
+ ],
+ expectedDomains: [
+ "www.foo.com",
+ "www.bar.com",
+ "www.baz.com",
+ "www.qux.com",
+ ],
+ },
+ {
+ title: "Extract domain from an href's query param value.",
+ extractorInfos: [
+ {
+ selectors:
+ '#test6 .js-carousel-item-title, #test6 [data-layout="ad"] [data-testid="result-title-a"]',
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ expectedDomains: ["def.com"],
+ },
+ {
+ title:
+ "Extract domain from an href's query param value containing an href.",
+ extractorInfos: [
+ {
+ selectors: "#test7 a",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ queryParamValueIsHref: true,
+ },
+ },
+ ],
+ expectedDomains: ["def.com"],
+ },
+ {
+ title:
+ "The param value contains an invalid href while queryParamValueIsHref enabled.",
+ extractorInfos: [
+ {
+ selectors: "#test8 a",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ queryParamValueIsHref: true,
+ },
+ },
+ ],
+ expectedDomains: [],
+ },
+ {
+ title: "Param value is missing from the href.",
+ extractorInfos: [
+ {
+ selectors: "#test9 a",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ queryParamValueIsHref: true,
+ },
+ },
+ ],
+ expectedDomains: [],
+ },
+ {
+ title: "Extraction preserves order of domains within the page.",
+ extractorInfos: [
+ {
+ selectors:
+ '#test10 [data-layout="organic"] a[data-testid="result-title-a"]',
+ method: "href",
+ },
+ {
+ selectors: "#test10 [data-dtld]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "dtld",
+ },
+ },
+ {
+ selectors:
+ '#test10 .js-carousel-item-title, #test7 [data-layout="ad"] [data-testid="result-title-a"]',
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ expectedDomains: ["foobar.com", "www.abc.com", "def.com"],
+ },
+ {
+ title: "No elements match the selectors.",
+ extractorInfos: [
+ {
+ selectors:
+ '#test11 [data-layout="organic"] a[data-testid="result-title-a"]',
+ method: "href",
+ },
+ ],
+ expectedDomains: [],
+ },
+ {
+ title: "Data attribute is present, but value is missing.",
+ extractorInfos: [
+ {
+ selectors: "#test12 [data-dtld]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "dtld",
+ },
+ },
+ ],
+ expectedDomains: [],
+ },
+ {
+ title: "Query param is present, but value is missing.",
+ extractorInfos: [
+ {
+ selectors: '#test13 [data-layout="ad"] [data-testid="result-title-a"]',
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ expectedDomains: [],
+ },
+ {
+ title: "Non-standard URL scheme.",
+ extractorInfos: [
+ {
+ selectors:
+ '#test14 [data-layout="organic"] a[data-testid="result-title-a"]',
+ method: "href",
+ },
+ ],
+ expectedDomains: [],
+ },
+];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.serpEventTelemetry.enabled", true],
+ ["browser.search.serpEventTelemetryCategorization.enabled", true],
+ ],
+ });
+
+ await SearchSERPTelemetry.init();
+
+ registerCleanupFunction(async () => {
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_domain_extraction_heuristics() {
+ resetTelemetry();
+ let url = getSERPUrl("searchTelemetryDomainExtraction.html");
+ info(
+ "Load a sample SERP where domains need to be extracted in different ways."
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ for (let currentTest of TESTS) {
+ if (currentTest.title) {
+ info(currentTest.title);
+ }
+ let expectedDomains = new Set(currentTest.expectedDomains);
+ let actualDomains = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [currentTest.extractorInfos],
+ extractorInfos => {
+ const { domainExtractor } = ChromeUtils.importESModule(
+ "resource:///actors/SearchSERPTelemetryChild.sys.mjs"
+ );
+ return domainExtractor.extractDomainsFromDocument(
+ content.document,
+ extractorInfos
+ );
+ }
+ );
+
+ Assert.deepEqual(
+ Array.from(actualDomains),
+ Array.from(expectedDomains),
+ "Domains should have been extracted correctly."
+ );
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js
new file mode 100644
index 0000000000..f328bb4f79
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * These tests check that changing the region actually results in reporting the
+ * correct changes. Other tests that include region just report the default
+ * used by the test.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ Region: "resource://gre/modules/Region.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ // The search telemetry entry responsible for targeting the specific results.
+ domainExtraction: {
+ ads: [
+ {
+ selectors: "[data-ad-domain]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "adDomain",
+ },
+ },
+ {
+ selectors: ".ad",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ nonAds: [
+ {
+ selectors: "#results .organic a",
+ method: "href",
+ },
+ ],
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+const originalHomeRegion = Region.home;
+const originalCurrentRegion = Region.current;
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+
+ let promise = waitForDomainToCategoriesUpdate();
+ await insertRecordIntoCollectionAndSync();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await promise;
+
+ info("Change region to DE.");
+ Region._setHomeRegion("DE", false);
+ Assert.equal(Region.home, "DE", "Region");
+
+ registerCleanupFunction(async () => {
+ Region._setHomeRegion(originalHomeRegion);
+ Region._setCurrentRegion(originalCurrentRegion);
+
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_categorize_page_with_different_region() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: "DE",
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js
new file mode 100644
index 0000000000..b7edb8763f
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * This test ensures we are correctly reporting categorized domains from a SERP.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ CATEGORIZATION_SETTINGS: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [
+ /^https:\/\/example\.com\/ad/,
+ /^https:\/\/www\.test(1[3456789]|2[01234])\.com/,
+ ],
+ // The search telemetry entry responsible for targeting the specific results.
+ domainExtraction: {
+ ads: [
+ {
+ selectors: "[data-ad-domain]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "adDomain",
+ },
+ },
+ {
+ selectors: ".ad",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ nonAds: [
+ {
+ selectors: "#results .organic a",
+ method: "href",
+ },
+ ],
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY);
+const db = client.db;
+
+let categorizationRecord;
+let categorizationAttachment;
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+
+ let { record, attachment } = await insertRecordIntoCollection();
+ categorizationRecord = record;
+ categorizationAttachment = attachment;
+
+ let promise = waitForDomainToCategoriesUpdate();
+ await syncCollection(record);
+ // Enable the preference since all tests rely on it to be turned on.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await promise;
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ await db.clear();
+ });
+});
+
+add_task(async function test_categorization_reporting() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a sample SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+});
+
+add_task(async function test_no_reporting_if_download_failure() {
+ resetTelemetry();
+
+ // Delete the attachment associated with the record so that syncing
+ // will cause an error.
+ await client.attachments.cacheImpl.delete(categorizationRecord.id);
+
+ let observeDownloadError = TestUtils.consoleMessageObserved(msg => {
+ return (
+ typeof msg.wrappedJSObject.arguments?.[0] == "string" &&
+ msg.wrappedJSObject.arguments[0].includes("Could not download file:")
+ );
+ });
+ // Since the preference is already enabled, and the map is filled we trigger
+ // the map to be updated via an RS sync. The download failure should cause the
+ // map to remain empty.
+ await syncCollection(categorizationRecord);
+ await observeDownloadError;
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a sample SERP with organic results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([]);
+
+ // Re-insert the attachment for other tests.
+ await client.attachments.cacheImpl.set(
+ categorizationRecord.id,
+ categorizationAttachment
+ );
+});
+
+add_task(async function test_no_reporting_if_no_records() {
+ resetTelemetry();
+
+ let observeNoRecords = TestUtils.consoleMessageObserved(msg => {
+ return (
+ typeof msg.wrappedJSObject.arguments?.[0] == "string" &&
+ msg.wrappedJSObject.arguments[0].includes(
+ "No records found for domain-to-categories map."
+ )
+ );
+ });
+ await syncCollection();
+ await observeNoRecords;
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a sample SERP with organic results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([]);
+});
+
+// Per a request from Data Science, we need to limit the number of domains
+// categorized to 10 non ad domains and 10 ad domains.
+add_task(async function test_reporting_limited_to_10_domains_of_each_kind() {
+ resetTelemetry();
+
+ await insertRecordIntoCollectionAndSync();
+
+ let url = getSERPUrl(
+ "searchTelemetryDomainCategorizationCapProcessedDomains.html"
+ );
+ info(
+ "Load a sample SERP with more than 10 organic results and more than 10 sponsored results."
+ );
+ let domainsCategorizedPromise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await domainsCategorizedPromise;
+
+ await BrowserTestUtils.removeTab(tab);
+
+ assertCategorizationValues([
+ {
+ organic_category: "0",
+ organic_num_domains:
+ CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE.toString(),
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "10",
+ sponsored_category: "2",
+ sponsored_num_domains:
+ CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE.toString(),
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "8",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "12",
+ },
+ ]);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js
new file mode 100644
index 0000000000..cfb8590960
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js
@@ -0,0 +1,287 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * These tests check that we report the categorization if the SERP is loaded,
+ * and the user idles. The tests also check that if we report the
+ * categorization and trigger another event that could cause a reporting, we
+ * don't cause more than one categorization to be reported.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchSERPCategorizationEventScheduler:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ // The search telemetry entry responsible for targeting the specific results.
+ domainExtraction: {
+ ads: [
+ {
+ selectors: "[data-ad-domain]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "adDomain",
+ },
+ },
+ {
+ selectors: ".ad",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ nonAds: [
+ {
+ selectors: "#results .organic a",
+ method: "href",
+ },
+ ],
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ SearchTestUtils.useMockIdleService();
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+
+ // On startup, the event scheduler is initialized.
+ // If serpEventTelemetryCategorization is already true, the instance of the
+ // class will be subscribed to to the real idle service instead of the mock
+ // idle service. If it's false, toggling the preference (which happens later
+ // in this setup) will initialize it.
+ if (
+ Services.prefs.getBoolPref(
+ "browser.search.serpEventTelemetryCategorization.enabled"
+ )
+ ) {
+ SearchSERPCategorizationEventScheduler.uninit();
+ SearchSERPCategorizationEventScheduler.init();
+ }
+ await waitForIdle();
+
+ let promise = waitForDomainToCategoriesUpdate();
+ await insertRecordIntoCollectionAndSync();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await promise;
+
+ registerCleanupFunction(async () => {
+ // The scheduler uses the mock idle service.
+ SearchSERPCategorizationEventScheduler.uninit();
+ SearchSERPCategorizationEventScheduler.init();
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_categorize_serp_and_wait() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ assertCategorizationValues([]);
+
+ promise = waitForAllCategorizedEvents();
+ SearchTestUtils.idleService._fireObservers("idle");
+ await promise;
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+
+ info("Ensure we don't record a duplicate of this event.");
+ resetTelemetry();
+ SearchTestUtils.idleService._fireObservers("idle");
+ SearchTestUtils.idleService._fireObservers("active");
+ await BrowserTestUtils.removeTab(tab);
+
+ assertCategorizationValues([]);
+});
+
+add_task(async function test_categorize_serp_open_multiple_tabs() {
+ resetTelemetry();
+
+ let tabs = [];
+ let expectedResults = [];
+ for (let i = 0; i < 5; ++i) {
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+ tabs.push(tab);
+ // Pushing expected results into a single array to avoid having a massive,
+ // unreadable array.
+ expectedResults.push({
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ });
+ }
+
+ info("Simulate idle event and wait for results.");
+ let promise = waitForAllCategorizedEvents();
+ SearchTestUtils.idleService._fireObservers("idle");
+ await promise;
+ assertCategorizationValues(expectedResults);
+
+ info("Ensure we don't record a duplicate of any event.");
+ resetTelemetry();
+ for (let tab of tabs) {
+ await BrowserTestUtils.removeTab(tab);
+ }
+ assertCategorizationValues([]);
+});
+
+// Ensures we don't double record a categorization event if the closed the tab
+// before an idle event.
+add_task(async function test_categorize_serp_close_tab_and_wait() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ assertCategorizationValues([]);
+
+ promise = waitForSingleCategorizedEvent();
+ await BrowserTestUtils.removeTab(tab);
+ await promise;
+
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+
+ info("Ensure we don't record a duplicate of this event.");
+ resetTelemetry();
+ SearchTestUtils.idleService._fireObservers("idle");
+ assertCategorizationValues([]);
+});
+
+add_task(async function test_categorize_serp_open_ad_and_wait() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ info("Open ad in new tab.");
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ ".ad",
+ { button: 1 },
+ tab.linkedBrowser
+ );
+ let tab2 = await promiseTabOpened;
+
+ assertCategorizationValues([]);
+
+ promise = waitForAllCategorizedEvents();
+ SearchTestUtils.idleService._fireObservers("idle");
+ info("Waiting for categorized events.");
+ await promise;
+
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "1",
+ num_ads_visible: "2",
+ },
+ ]);
+
+ // Clean up.
+ await BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js
new file mode 100644
index 0000000000..cb95164221
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js
@@ -0,0 +1,202 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * These tests check that we report the SERP categorization upon waking a
+ * computer and enough time has passed.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ CATEGORIZATION_SETTINGS: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPCategorizationEventScheduler:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ TELEMETRY_CATEGORIZATION_KEY:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ // The search telemetry entry responsible for targeting the specific results.
+ domainExtraction: {
+ ads: [
+ {
+ selectors: "[data-ad-domain]",
+ method: "data-attribute",
+ options: {
+ dataAttributeKey: "adDomain",
+ },
+ },
+ {
+ selectors: ".ad",
+ method: "href",
+ options: {
+ queryParamKey: "ad_domain",
+ },
+ },
+ ],
+ nonAds: [
+ {
+ selectors: "#results .organic a",
+ method: "href",
+ },
+ ],
+ },
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ let oldWakeTimeout = CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS;
+
+ // Use a sane timeout.
+ CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS = 100;
+
+ SearchTestUtils.useMockIdleService();
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ // On startup, the event scheduler is initialized.
+ // If serpEventTelemetryCategorization is already true, the instance of the
+ // class will be subscribed to to the real idle service instead of the mock
+ // idle service. If it's false, toggling the preference (which happens later
+ // in this setup) will initialize it.
+ if (
+ Services.prefs.getBoolPref(
+ "browser.search.serpEventTelemetryCategorization.enabled"
+ )
+ ) {
+ SearchSERPCategorizationEventScheduler.uninit();
+ SearchSERPCategorizationEventScheduler.init();
+ }
+ await waitForIdle();
+
+ let promise = waitForDomainToCategoriesUpdate();
+ await insertRecordIntoCollectionAndSync();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.serpEventTelemetryCategorization.enabled", true]],
+ });
+ await promise;
+
+ registerCleanupFunction(async () => {
+ // The scheduler uses the mock idle service.
+ SearchSERPCategorizationEventScheduler.uninit();
+ SearchSERPCategorizationEventScheduler.init();
+ CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS = oldWakeTimeout;
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_categorize_serp_and_sleep() {
+ resetTelemetry();
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ assertCategorizationValues([]);
+
+ info("Wait enough between the categorization and the sleep timeout.");
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 200));
+
+ info("Simulate a wake notification.");
+ promise = waitForAllCategorizedEvents();
+ SearchTestUtils.idleService._fireObservers("wake_notification");
+ await promise;
+
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+
+ info("Ensure we don't record a duplicate of this event.");
+ resetTelemetry();
+ SearchTestUtils.idleService._fireObservers("idle");
+ SearchTestUtils.idleService._fireObservers("active");
+ SearchTestUtils.idleService._fireObservers("wake_notification");
+ await BrowserTestUtils.removeTab(tab);
+
+ assertCategorizationValues([]);
+});
+
+add_task(async function test_categorize_serp_and_sleep_not_long_enough() {
+ resetTelemetry();
+
+ // Use a really long timeout.
+ CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS = 500_000;
+
+ let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html");
+ info("Load a SERP with organic and sponsored results.");
+ let promise = waitForPageWithCategorizedDomains();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await promise;
+
+ assertCategorizationValues([]);
+
+ info("Wait as long as the previous test.");
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 200));
+ assertCategorizationValues([]);
+
+ info("Simulate a wake notification.");
+ SearchTestUtils.idleService._fireObservers("wake_notification");
+ assertCategorizationValues([]);
+
+ // Closing the tab should record the telemetry.
+ await BrowserTestUtils.removeTab(tab);
+ assertCategorizationValues([
+ {
+ organic_category: "3",
+ organic_num_domains: "1",
+ organic_num_inconclusive: "0",
+ organic_num_unknown: "0",
+ sponsored_category: "4",
+ sponsored_num_domains: "2",
+ sponsored_num_inconclusive: "0",
+ sponsored_num_unknown: "0",
+ mappings_version: "1",
+ app_version: APP_MAJOR_VERSION,
+ channel: CHANNEL,
+ region: REGION,
+ partner_code: "ff",
+ provider: "example",
+ tagged: "true",
+ num_ads_clicked: "0",
+ num_ads_visible: "2",
+ },
+ ]);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js
new file mode 100644
index 0000000000..791e29a01f
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js
@@ -0,0 +1,201 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/,
+ queryParamNames: ["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,
+ },
+ ],
+ },
+];
+
+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.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/telemetry/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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "5",
+ ads_visible: "5",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "5",
+ ads_visible: "5",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js
new file mode 100644
index 0000000000..72e26639fb
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js
@@ -0,0 +1,218 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/,
+ queryParamNames: ["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,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [["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.startLoadingURIString(
+ 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.startLoadingURIString(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/telemetry/browser_search_telemetry_engagement_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js
new file mode 100644
index 0000000000..a7ea62ebd5
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js
@@ -0,0 +1,633 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_searchbox_with_content.html/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/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,
+ },
+ ],
+ },
+];
+
+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.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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "true",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_incontent_search",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ 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/telemetry/browser_search_telemetry_engagement_multiple_tabs.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js
new file mode 100644
index 0000000000..fbe6f4fc73
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js
@@ -0,0 +1,206 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/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;
+
+add_setup(async function () {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["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/telemetry/browser_search_telemetry_engagement_non_ad.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js
new file mode 100644
index 0000000000..d351234d50
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js
@@ -0,0 +1,146 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "13",
+ ads_visible: "13",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "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/telemetry/browser_search_telemetry_engagement_query_params.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js
new file mode 100644
index 0000000000..6d93707d68
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js
@@ -0,0 +1,387 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load SERPs and check that query params that are changed either
+ * by the browser or in the page after click are still properly recognized
+ * as ads.
+ *
+ */
+
+"use strict";
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ included: {
+ parent: {
+ selector: ".moz_ad",
+ },
+ children: [
+ {
+ selector: ".multi-col",
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ },
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.serpEventTelemetry.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+// Baseline test clicking on either link properly categorizes both properly.
+add_task(async function test_click_links() {
+ let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html");
+
+ info("Load SERP.");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ info("Click on ad link.");
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#ad_link",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ info("Load SERP again.");
+ BrowserTestUtils.startLoadingURIString(gBrowser, url);
+ pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await waitForPageWithAdImpressions();
+
+ info("Click on site link.");
+ pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#ad_sitelink",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean up.
+ BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+});
+
+add_task(async function test_click_link_with_more_parameters() {
+ let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html");
+
+ info("Load SERP.");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ info("After ad impressions, add query parameters to DOM element.");
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let el = content.document.getElementById("ad_sitelink");
+ let domUrl = new URL(el.href);
+ domUrl.searchParams.set("example", "param");
+ el.setAttribute("href", domUrl.toString());
+ });
+
+ info("Click on site link.");
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#ad_sitelink",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean up.
+ BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+});
+
+add_task(async function test_click_link_with_fewer_parameters() {
+ let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html");
+
+ info("Load SERP.");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ info("After ad impressions, remove a query parameter from a DOM element.");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let el = content.document.getElementById("ad_sitelink");
+ let domUrl = new URL(el.href);
+ domUrl.searchParams.delete("foo");
+ el.setAttribute("href", domUrl.toString());
+ });
+
+ info("Click on site link.");
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#ad_sitelink",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean up.
+ BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+});
+
+add_task(async function test_click_link_with_reordered_parameters() {
+ let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html");
+
+ info("Load SERP.");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ info("After ad impressions, re-sort the query params.");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let el = content.document.getElementById("ad_sitelink");
+ let domUrl = new URL(el.href);
+ domUrl.searchParams.sort();
+ el.setAttribute("href", domUrl.toString());
+ });
+
+ info("Click on site link.");
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a#ad_sitelink",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean up.
+ BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js
new file mode 100644
index 0000000000..5d7f2ee408
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js
@@ -0,0 +1,372 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_nonAdsLink_redirect.html/,
+ ],
+ extraAdServersRegexps: [
+ /^https:\/\/example\.com\/ad/,
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/redirect_ad/,
+ ],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "14",
+ ads_visible: "14",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "14",
+ ads_visible: "14",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "14",
+ ads_visible: "14",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "14",
+ ads_visible: "14",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "14",
+ ads_visible: "14",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "14",
+ ads_visible: "14",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js
new file mode 100644
index 0000000000..b30a7bc0c1
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js
@@ -0,0 +1,457 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/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: [],
+ },
+];
+
+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.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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "5",
+ ads_visible: "5",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "5",
+ ads_visible: "5",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "form input",
+ {},
+ tab.linkedBrowser
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ await pageLoadPromise;
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "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",
+ is_private: "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);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#suggest",
+ {},
+ tab.linkedBrowser
+ );
+ await pageLoadPromise;
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "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",
+ is_private: "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();
+ });
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.EXPANDED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL,
+ ads_loaded: "4",
+ ads_visible: "3",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "5",
+ ads_visible: "5",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Reset state for other tests.
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO);
+ await waitForIdle();
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js
new file mode 100644
index 0000000000..4f943fe92d
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js
@@ -0,0 +1,350 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load ads and organic links in new windows.
+ */
+
+"use strict";
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.serpEventTelemetry.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function load_serp_in_new_window_with_pref_and_click_ad() {
+ info("Set browser.link.open_newwindow to open _blank in new window.");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.link.open_newwindow", 2]],
+ });
+
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetryAd.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ info("Wait for page impression.");
+ await waitForPageWithAdImpressions();
+
+ info("Open ad link in a new window.");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.document.getElementById("ad1").setAttribute("target", "_blank");
+ });
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: "https://example.com/ad",
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser);
+ let newWindow = await newWindowPromise;
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ "browser.search.adclicks.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean-up.
+ await SpecialPowers.popPrefEnv();
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(newWindow);
+ resetTelemetry();
+});
+
+add_task(async function load_serp_in_new_window_with_pref_and_click_organic() {
+ info("Set browser.link.open_newwindow to open _blank in new window.");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.link.open_newwindow", 2]],
+ });
+
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetry.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ info("Wait for page impression.");
+ await waitForPageWithAdImpressions();
+
+ info("Open organic link in a new window.");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.document.querySelector("a").setAttribute("target", "_blank");
+ });
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: "https://example.com/otherpage",
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter("a", {}, tab.linkedBrowser);
+ let newWindow = await newWindowPromise;
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [],
+ },
+ ]);
+
+ // Clean-up.
+ await SpecialPowers.popPrefEnv();
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(newWindow);
+ resetTelemetry();
+});
+
+add_task(async function load_serp_in_new_window_with_context_menu() {
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetryAd.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ info("Wait for page impression.");
+ await waitForPageWithAdImpressions();
+
+ info("Open context menu.");
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad1",
+ { type: "contextmenu", button: 2 },
+ tab.linkedBrowser
+ );
+ await contextMenuPromise;
+
+ info("Click on Open Link in New Window");
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: "https://example.com/ad",
+ });
+ let openLinkInNewWindow = contextMenu.querySelector("#context-openlink");
+ contextMenu.activateItem(openLinkInNewWindow);
+ let newWindow = await newWindowPromise;
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ "browser.search.adclicks.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean-up.
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(newWindow);
+ resetTelemetry();
+});
+
+add_task(
+ async function load_multiple_serps_with_different_search_terms_and_click_ad() {
+ info("Set browser.link.open_newwindow to open _blank in new window.");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.link.open_newwindow", 2]],
+ });
+
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetryAd.html");
+ let formattedUrl1 = new URL(url);
+ formattedUrl1.searchParams.set("s", "test1");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ info("Wait for page impression.");
+ await waitForPageWithAdImpressions();
+
+ info("Load SERP in a new tab with a different search term.");
+ url = getSERPUrl("searchTelemetryAd.html");
+ let formattedUrl2 = new URL(url);
+ formattedUrl2.searchParams.set("s", "test2");
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ formattedUrl2.href
+ );
+ info("Wait for page impression of tab 2.");
+ await waitForPageWithAdImpressions();
+
+ Assert.notEqual(
+ formattedUrl1.searchParams.get("s"),
+ formattedUrl2.searchParams.get("s"),
+ "The search query param in both tabs are different."
+ );
+
+ info("Open ad link of tab 2 in a new window.");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ content.document.getElementById("ad1").setAttribute("target", "_blank");
+ });
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: "https://example.com/ad",
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad1",
+ {},
+ tab2.linkedBrowser
+ );
+ let newWindow = await newWindowPromise;
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 2 },
+ "browser.search.withads.unknown": { "example:tagged": 2 },
+ "browser.search.adclicks.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean-up.
+ await SpecialPowers.popPrefEnv();
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ await BrowserTestUtils.closeWindow(newWindow);
+ resetTelemetry();
+ }
+);
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js
new file mode 100644
index 0000000000..ea7556c8f6
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js
@@ -0,0 +1,135 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests load SERPs in Private Browsing Mode. Existing tests do so in
+ * non-Private Browsing Mode.
+ */
+
+"use strict";
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.serpEventTelemetry.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function load_2_pbm_serps_and_1_non_pbm_serp() {
+ info("Open private browsing window.");
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetry.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ privateWindow.gBrowser,
+ url
+ );
+ info("Wait for page impression.");
+ await waitForPageWithAdImpressions();
+
+ info("Load another SERP in the same tab.");
+ url = getSERPUrl("searchTelemetryAd.html");
+ BrowserTestUtils.startLoadingURIString(privateWindow.gBrowser, url);
+ info("Wait for page impression.");
+ await waitForPageWithAdImpressions();
+
+ info("Close private window.");
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(privateWindow);
+
+ info("Load SERP in non-private window.");
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ info("Wait for page impression.");
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "true",
+ shopping_tab_displayed: "false",
+ },
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "true",
+ shopping_tab_displayed: "false",
+ },
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Clean-up.
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js
new file mode 100644
index 0000000000..5f2afcf6fc
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js
@@ -0,0 +1,329 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * When Remote Settings receives an update to search-telemetry-v2, we should
+ * trigger an update within SearchSERPTelemetry and SearchSERPTelemetryChild
+ * without requiring a user to restart their browser.
+ */
+
+requestLongerTimeout(5);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ADLINK_CHECK_TIMEOUT_MS: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SEARCH_TELEMETRY_SHARED: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPDomainToCategoriesMap:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ TELEMETRY_SETTINGS_KEY: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+});
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ nonAdsLinkRegexps: [],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+const TEST_PROVIDER_BROKEN_VARIANT = [
+ {
+ ...TEST_PROVIDER_INFO[0],
+ queryParamNames: ["foo"],
+ },
+];
+
+const RECORDS = {
+ current: TEST_PROVIDER_INFO,
+ created: [],
+ updated: TEST_PROVIDER_INFO,
+ deleted: [],
+};
+
+const BROKEN_VARIANT_RECORDS = {
+ current: TEST_PROVIDER_BROKEN_VARIANT,
+ created: [],
+ updated: TEST_PROVIDER_BROKEN_VARIANT,
+ deleted: [],
+};
+
+const client = RemoteSettings(TELEMETRY_SETTINGS_KEY);
+const db = client.db;
+let record = TEST_PROVIDER_INFO[0];
+
+async function updateClientWithRecords(records) {
+ let promise = TestUtils.topicObserved("search-telemetry-v2-synced");
+
+ await client.emit("sync", { data: records });
+
+ info("Wait for SearchSERPTelemetry to update.");
+ await promise;
+}
+
+add_setup(async function () {
+ // Initialize the test with a variant of telemetry that won't trigger an
+ // impression due to an odd query param name.
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(
+ TEST_PROVIDER_BROKEN_VARIANT
+ );
+ await waitForIdle();
+ // Enable local telemetry recording for the duration of the tests.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.serpEventTelemetry.enabled", true],
+ // Set the IPC count to a small number so that we only have to open
+ // one additional tab to reuse the same process.
+ ["dom.ipc.processCount.webIsolated", 1],
+ ],
+ });
+
+ // Shorten the time it takes to examine pages for ads.
+ Services.ppmm.sharedData.set(SEARCH_TELEMETRY_SHARED.LOAD_TIMEOUT, 500);
+ Services.ppmm.sharedData.flush();
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ await db.clear();
+ await SpecialPowers.popPrefEnv();
+ Services.ppmm.sharedData.set(
+ SEARCH_TELEMETRY_SHARED.LOAD_TIMEOUT,
+ ADLINK_CHECK_TIMEOUT_MS
+ );
+ Services.ppmm.sharedData.flush();
+ });
+});
+
+add_task(async function update_telemetry_tab_already_open() {
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetryAd.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ info("Wait a brief amount of time for a possible SERP impression.");
+ await waitForIdle();
+
+ info("Assert no impressions are found.");
+ assertSERPTelemetry([]);
+
+ info("Update search-telemetry-v2 with a matching queryParamName.");
+ await updateClientWithRecords(RECORDS);
+
+ info("Reload page.");
+ gBrowser.reload();
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "reload",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Change search-telemetry-v2 back to the broken variant so that the next
+ // test can check updating the collection while no tabs are open results
+ // in a SERP check.
+ info("Update search-telemetry-v2 with non-matching queryParamName.");
+ await updateClientWithRecords(BROKEN_VARIANT_RECORDS);
+
+ info("Remove tab and reset telemetry.");
+ await BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+});
+
+add_task(async function update_telemetry_tab_closed() {
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetryAd.html");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ info("Wait a brief amount of time for a possible SERP impression.");
+ await waitForIdle();
+
+ info("Assert no impressions are found.");
+ assertSERPTelemetry([]);
+
+ info("Remove tab.");
+ await BrowserTestUtils.removeTab(tab);
+
+ info("Update search-telemetry-v2 with a matching queryParamName.");
+ await updateClientWithRecords(RECORDS);
+
+ info("Load SERP in a new tab.");
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ info("Update search-telemetry-v2 with non-matching queryParamName.");
+ await updateClientWithRecords(BROKEN_VARIANT_RECORDS);
+
+ info("Remove tab and reset telemetry.");
+ await BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+});
+
+add_task(async function update_telemetry_multiple_tabs() {
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetryAd.html");
+
+ let tabs = [];
+ for (let index = 0; index < 5; ++index) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ tabs.push(tab);
+ }
+
+ info("Wait a brief amount of time for a possible SERP impression.");
+ await waitForIdle();
+
+ info("Assert no impressions are found.");
+ assertSERPTelemetry([]);
+
+ info("Update search-telemetry-v2 with a matching queryParamName.");
+ await updateClientWithRecords(RECORDS);
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ gBrowser.reload();
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "reload",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+ await BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+ }
+
+ info("Update search-telemetry-v2 with non-matching queryParamName.");
+ await updateClientWithRecords(BROKEN_VARIANT_RECORDS);
+});
+
+add_task(async function update_telemetry_multiple_processes_and_tabs() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Set the IPC count to a higher number to allow for multiple processes
+ // for the same domain to be available.
+ ["dom.ipc.processCount.webIsolated", 4],
+ ],
+ });
+
+ info("Load SERP in a new tab.");
+ let url = getSERPUrl("searchTelemetryAd.html");
+
+ let tabs = [];
+ for (let index = 0; index < 8; ++index) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ tabs.push(tab);
+ }
+
+ info("Wait a brief amount of time for a possible SERP impression.");
+ await waitForIdle();
+
+ info("Assert no impressions are found.");
+ assertSERPTelemetry([]);
+
+ info("Update search-telemetry-v2 with a matching queryParamName.");
+ await updateClientWithRecords(RECORDS);
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ gBrowser.reload();
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "reload",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+ resetTelemetry();
+ }
+
+ info("Update search-telemetry-v2 with non-matching queryParamName.");
+ await updateClientWithRecords(BROKEN_VARIANT_RECORDS);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js
new file mode 100644
index 0000000000..b9f85aaefa
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js
@@ -0,0 +1,442 @@
+"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,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "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/telemetry/browser_search_telemetry_shopping.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js
new file mode 100644
index 0000000000..e2352b53f4
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js
@@ -0,0 +1,143 @@
+/* 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";
+
+// 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\/telemetry\/searchTelemetryAd/,
+ queryParamNames: ["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,
+ },
+ },
+];
+
+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.serpEventTelemetry.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+async function loadSerpAndClickShoppingTab(page) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl(page)
+ );
+ await waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ BrowserTestUtils.synthesizeMouseAtCenter("#shopping", {}, tab.linkedBrowser);
+ await pageLoadPromise;
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "true",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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/telemetry/browser_search_telemetry_sources.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js
new file mode 100644
index 0000000000..7fa66a1adf
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js
@@ -0,0 +1,349 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+/**
+ * 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;
+}
+
+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;
+
+ 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();
+ 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 adImpressionPromise = waitForPageWithAdImpressions();
+ let tab = await searchAdsFn();
+
+ await assertSearchSourcesTelemetry(
+ {
+ [expectedHistogramSAPSourceKey]: 1,
+ },
+ {
+ [expectedContentScalar]: { [expectedContentScalarKey]: 1 },
+ [expectedWithAdsScalar]: { [expectedScalarKey]: 1 },
+ }
+ );
+
+ await adImpressionPromise;
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: expectedScalarSource,
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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.startLoadingURIString(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);
+ }
+ );
+});
+
+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);
+ }
+ );
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js
new file mode 100644
index 0000000000..a313c75ac7
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js
@@ -0,0 +1,225 @@
+/* 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 on about pages.
+ *
+ */
+
+"use strict";
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+/**
+ * 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;
+}
+
+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;
+
+ 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();
+ 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 adImpressionPromise = waitForPageWithAdImpressions();
+ let tab = await searchAdsFn();
+
+ await assertSearchSourcesTelemetry(
+ {
+ [expectedHistogramSAPSourceKey]: 1,
+ },
+ {
+ [expectedContentScalar]: { [expectedContentScalarKey]: 1 },
+ [expectedWithAdsScalar]: { [expectedScalarKey]: 1 },
+ }
+ );
+
+ await adImpressionPromise;
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: expectedScalarSource,
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await cleanupFn();
+
+ Services.fog.testResetFOG();
+}
+
+async function checkAboutPage(
+ page,
+ expectedHistogramSource,
+ expectedScalarSource
+) {
+ let tab;
+ await track_ad_click(
+ expectedHistogramSource,
+ expectedScalarSource,
+ async () => {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ BrowserTestUtils.startLoadingURIString(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");
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js
new file mode 100644
index 0000000000..0fd93da30f
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js
@@ -0,0 +1,378 @@
+/* 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";
+
+// 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\/telemetry\/searchTelemetry(?:Ad)?.html/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+function getSERPFollowOnUrl(page) {
+ return page + "?s=test&abc=ff&a=foo";
+}
+
+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.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("searchTelemetry.html"),
+ },
+ async () => {
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ }
+ );
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ },
+ ]);
+});
+
+add_task(async function test_simple_search_page_visit_telemetry() {
+ resetTelemetry();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ /* URL must not be in the cache */
+ url: getSERPUrl("searchTelemetry.html") + `&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");
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ },
+ ]);
+});
+
+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,
+ },
+ }
+ );
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE,
+ },
+ },
+ ]);
+});
+
+add_task(async function test_track_ad() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_organic() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd.html", true)
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:organic:none": 1 },
+ "browser.search.withads.unknown": { "example:organic": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_new_window() {
+ resetTelemetry();
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ let url = getSERPUrl("searchTelemetryAd.html");
+ BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url);
+ await BrowserTestUtils.browserLoaded(
+ win.gBrowser.selectedBrowser,
+ false,
+ url
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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("searchTelemetry.html")
+ )
+ );
+ await waitForPageWithAdImpressions();
+
+ tabs.push(
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd.html")
+ )
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 2 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js
new file mode 100644
index 0000000000..11d2176563
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js
@@ -0,0 +1,373 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for SearchSERPTelemetry associated with ad clicks.
+ */
+
+"use strict";
+
+// 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\/telemetry\/searchTelemetry(?:Ad)?.html/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.serpEventTelemetry.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+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("searchTelemetryAd.html", testOrganic)
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { [expectedContentScalarKey]: 1 },
+ "browser.search.withads.unknown": {
+ [expectedScalarKey.replace("sap", "tagged")]: 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Now go back, and click again.
+ pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser);
+ gBrowser.goBack();
+ await pageLoadPromise;
+ await waitForPageWithAdImpressions();
+
+ // 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged,
+ partner_code: partnerCode,
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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("searchTelemetryAd.html");
+ let adImpressionPromise = waitForPageWithAdImpressions();
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+ await adImpressionPromise;
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(newTab);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js
new file mode 100644
index 0000000000..3c5e0a464e
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js
@@ -0,0 +1,173 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for SearchSERPTelemetry associated with ad links found in data attributes.
+ */
+
+"use strict";
+
+// 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-data-attributes",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_dataAttributes(?:_none|_href)?.html/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["xyz"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.serpEventTelemetry.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_track_ad_on_data_attributes() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd_dataAttributes.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example-data-attributes:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example-data-attributes:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example-data-attributes",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_on_data_attributes_and_hrefs() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd_dataAttributes_href.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example-data-attributes:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example-data-attributes:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example-data-attributes",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_no_ad_on_data_attributes_and_hrefs() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("searchTelemetryAd_dataAttributes_none.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example-data-attributes:tagged:ff": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example-data-attributes",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js
new file mode 100644
index 0000000000..069e13d339
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests for SearchSERPTelemetry associated with ad links and load events.
+ */
+
+"use strict";
+
+// 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: "slow-page-load",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/slow_loading_page_with_ads(_on_load_event)?.html/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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.serpEventTelemetry.enabled", true]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_track_ad_on_DOMContentLoaded() {
+ resetTelemetry();
+
+ 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("slow_loading_page_with_ads.html")
+ );
+
+ // 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "slow-page-load",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_track_ad_on_load_event() {
+ resetTelemetry();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ getSERPUrl("slow_loading_page_with_ads_on_load_event.html")
+ );
+ await waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "slow-page-load:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "slow-page-load:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "slow-page-load",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js
new file mode 100644
index 0000000000..9bff667857
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js
@@ -0,0 +1,506 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/,
+ queryParamNames: ["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,
+ },
+ ],
+ },
+];
+
+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;
+
+ registerCleanupFunction(async () => {
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "opened_in_new_tab",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ adImpressions: [
+ {
+ component:
+ SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "false",
+ partner_code: "",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component:
+ SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ adImpressions: [
+ {
+ component:
+ SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component:
+ SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 waitForPageWithAdImpressions();
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_SERP",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS,
+ ads_loaded: "1",
+ ads_visible: "1",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js
new file mode 100644
index 0000000000..7ce681701a
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js
@@ -0,0 +1,684 @@
+/* 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 TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?.html/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+/**
+ * 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;
+}
+
+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;
+
+ 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);
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ resetTelemetry();
+ });
+});
+
+// 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 waitForPageWithAdImpressions();
+
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+});
+
+add_task(async function test_reload() {
+ let adImpressionPromise = waitForPageWithAdImpressions();
+ let promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ tab.linkedBrowser.reload();
+ await promise;
+ await adImpressionPromise;
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "reload",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "reload",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+});
+
+let searchUrl;
+
+add_task(async function test_fresh_search() {
+ resetTelemetry();
+
+ // Load a page via the address bar.
+ let adImpressionPromise = waitForPageWithAdImpressions();
+ await loadSearchPage();
+ await adImpressionPromise;
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+});
+
+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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+});
+
+add_task(async function test_go_back() {
+ let adImpressionPromise = waitForPageWithAdImpressions();
+ let promise = BrowserTestUtils.waitForLocationChange(gBrowser, searchUrl);
+ tab.linkedBrowser.goBack();
+ await promise;
+ await adImpressionPromise;
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+});
+
+// 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.
+ let adImpressionPromise = waitForPageWithAdImpressions();
+ await loadSearchPage();
+ await adImpressionPromise;
+ await assertSearchSourcesTelemetry(
+ {
+ "other-Example.urlbar": 1,
+ },
+ {
+ "browser.search.content.urlbar": { "example:tagged:ff": 1 },
+ "browser.search.withads.urlbar": { "example:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // Do another search from the context of the default SERP.
+ adImpressionPromise = waitForPageWithAdImpressions();
+ await loadSearchPage();
+ await adImpressionPromise;
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar_persisted",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ // 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: "urlbar_persisted",
+ shopping_tab_displayed: "false",
+ is_shopping_page: "false",
+ is_private: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js
new file mode 100644
index 0000000000..f7b22f004b
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js
@@ -0,0 +1,219 @@
+/* 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 with Web Extensions.
+ *
+ */
+
+"use strict";
+
+const TEST_PROVIDER_INFO = [
+ {
+ telemetryId: "example",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ followOnParamNames: ["a"],
+ extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/],
+ components: [
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ },
+];
+
+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;
+
+ 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();
+ 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 adImpressionPromise = waitForPageWithAdImpressions();
+ let tab = await searchAdsFn();
+
+ await assertSearchSourcesTelemetry(
+ {
+ [expectedHistogramSAPSourceKey]: 1,
+ },
+ {
+ [expectedContentScalar]: { [expectedContentScalarKey]: 1 },
+ [expectedWithAdsScalar]: { [expectedScalarKey]: 1 },
+ }
+ );
+
+ await adImpressionPromise;
+
+ 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 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example",
+ tagged: "true",
+ partner_code: "ff",
+ source: expectedScalarSource,
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await cleanupFn();
+
+ Services.fog.testResetFOG();
+}
+
+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/telemetry/browser_search_telemetry_spa_in_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js
new file mode 100644
index 0000000000..39270c7e9f
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js
@@ -0,0 +1,524 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Check SPA in-content interactions (e.g. search box, clicking autosuggest) and
+ * ensures we're correctly unloading / adding listeners to elements, and
+ * registering the right engagements for search submission events that could
+ * change the location of the page.
+ */
+
+"use strict";
+
+add_setup(async function () {
+ await initSinglePageAppTest();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount.webIsolated", 1]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_content_process_type_search_click_suggestion() {
+ resetTelemetry();
+
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.clickSearchboxAndType(tab);
+ await SinglePageAppUtils.clickSuggestion(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 2,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 2,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_incontent_search",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(
+ async function test_content_process_type_search_click_related_search() {
+ resetTelemetry();
+
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.clickSearchboxAndType(tab);
+ await SinglePageAppUtils.visitRelatedSearch(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 2,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 2,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+ await BrowserTestUtils.removeTab(tab);
+ }
+);
+
+add_task(async function test_content_process_engagement() {
+ resetTelemetry();
+
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.clickSearchbox(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_content_process_engagement_that_changes_page() {
+ resetTelemetry();
+
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.clickSuggestion(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 2,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 2,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_incontent_search",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+// This is to ensure if the user switches to another search page, we unload
+// the listeners, add them back in, and then accurately register the correct
+// number of engagements. The engagement target should also be accurate.
+add_task(
+ async function test_in_page_reload_and_content_process_engagement_that_changes_page() {
+ resetTelemetry();
+
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.visitRelatedSearch(tab);
+ await SinglePageAppUtils.clickSuggestion(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 3,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 3,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "follow_on_from_refine_on_incontent_search",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+ }
+);
+
+// Clicking on another SERP tab and selecting the searchbox shouldn't cause a
+// new engagement.
+add_task(async function test_unload_listeners_single_tab() {
+ resetTelemetry();
+
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.clickImagesTab(tab);
+ await SinglePageAppUtils.clickSearchbox(tab);
+ await SinglePageAppUtils.clickSuggestionOnImagesTab(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+// Make sure unloading listeners is specific to the tab.
+add_task(async function test_unload_listeners_multi_tab() {
+ resetTelemetry();
+
+ let tab1 = await SinglePageAppUtils.createTabAndLoadURL();
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL();
+
+ // Listener should no longer be applicable on tab2 because we're switching
+ // to tab2.
+ await SinglePageAppUtils.clickImagesTab(tab2);
+ await SinglePageAppUtils.clickSearchbox(tab2);
+ await SinglePageAppUtils.clickSuggestionOnImagesTab(tab2);
+
+ // Click a searchbox on tab1 to verify the listener is still working.
+ await SinglePageAppUtils.clickSearchbox(tab1);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 2,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 2,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js
new file mode 100644
index 0000000000..1e44957daa
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js
@@ -0,0 +1,529 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Check SPA page loads on two different providers that are both SPAs. A sanity
+ * check to ensure we're categorizing them separately. They differ by having
+ * different top level domains (.com vs .org).
+ */
+
+"use strict";
+
+add_setup(async function () {
+ await initSinglePageAppTest();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount.webIsolated", 1]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_load_serps_and_click_organic() {
+ resetTelemetry();
+
+ let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders();
+
+ for (let tab of tabs) {
+ await SinglePageAppUtils.clickOrganic(tab);
+ }
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 1,
+ "example2:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 1,
+ "example2:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ ]);
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function test_load_serps_and_click_ads() {
+ resetTelemetry();
+
+ let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders();
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ await SinglePageAppUtils.clickAd(tab);
+ }
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 1,
+ "example2:tagged:ff": 1,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 1,
+ "example2:tagged": 1,
+ },
+ "browser.search.adclicks.unknown": {
+ "example1:tagged": 1,
+ "example2:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function test_load_serps_and_click_related() {
+ resetTelemetry();
+
+ let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders();
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ await SinglePageAppUtils.visitRelatedSearch(tab);
+ }
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 2,
+ "example2:tagged:ff": 2,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 2,
+ "example2:tagged": 2,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function test_load_pages_tabhistory() {
+ resetTelemetry();
+
+ let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders();
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ await SinglePageAppUtils.visitRelatedSearch(tab);
+ await SinglePageAppUtils.goBack(tab);
+ await SinglePageAppUtils.goForward(tab);
+ }
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 2,
+ "example2:tagged:ff": 2,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 2,
+ "example2:tagged": 2,
+ },
+ "browser.search.content.tabhistory": {
+ "example1:tagged:ff": 2,
+ "example2:tagged:ff": 2,
+ },
+ "browser.search.withads.tabhistory": {
+ "example1:tagged": 2,
+ "example2:tagged": 2,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ // This is second because it was the second tab created.
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example2",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js
new file mode 100644
index 0000000000..478a995e97
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js
@@ -0,0 +1,875 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Check SPA page loads on a single provider using multiple tabs.
+ */
+
+"use strict";
+
+// Allow more time for Mac machines so they don't time out in verify mode.
+if (AppConstants.platform == "macosx") {
+ requestLongerTimeout(3);
+}
+
+add_setup(async function () {
+ await initSinglePageAppTest();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount.webIsolated", 1]],
+ });
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+// Deliberately has actions happening after one another to assert that the
+// different events are recorded properly.
+
+// One issue that can occur is if two SERPs have the same search term, if we
+// try finding the item for the URL, it might match the wrong item.
+// e.g. two tabs search for foobar
+// one tab then searches for barfoo
+// the other tab that had foobar does another search, but instead of referring
+// back to foobar, it looks at barfoo and messes with its state.
+
+// We use switch tabs to avoid `getBoundsWithoutFlushing` not returning the
+// latest visual info, which affects ad visibility counts.
+add_task(async function test_load_serps_and_click_related_searches() {
+ resetTelemetry();
+
+ let tab1 = await SinglePageAppUtils.createTabAndLoadURL();
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL();
+ let tab3 = await SinglePageAppUtils.createTabAndLoadURL();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab1);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await SinglePageAppUtils.visitRelatedSearch(tab2);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab3);
+ await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab3);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await SinglePageAppUtils.visitRelatedSearch(tab1);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab2);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab3);
+ await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab3);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 9,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 5,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ // Tab 1 - Visit a SERP, clicks a related search without ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visits a SERP, clicks a related SERP with ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 3 - Visit a SERP, clicks a related SERP without ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 1 - Visits a related SERP without ads, clicks on a related SERP
+ // with ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [],
+ },
+ {
+ // Tab 2 - Visits a related search with ads, clicks a related SERP
+ // without ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 3 - Visit a SERP without ads, clicks a related SERP without ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [],
+ },
+ {
+ // Tab 1 - Visit a related SERP with ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a related SERP without ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [],
+ },
+ {
+ // Tab 3 - Visit a related SERP without ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+ await BrowserTestUtils.removeTab(tab3);
+});
+
+/**
+ * The source of the ad click should match the correct tab.
+ */
+add_task(async function test_different_sources_click_ad() {
+ resetTelemetry();
+
+ let tab1 = await SinglePageAppUtils.createTabAndLoadURL();
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL();
+
+ await SinglePageAppUtils.visitRelatedSearch(tab2);
+ await SinglePageAppUtils.goBack(tab2);
+ await SinglePageAppUtils.clickAd(tab2);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 3,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 3,
+ },
+ "browser.search.content.tabhistory": {
+ "example1:tagged:ff": 1,
+ },
+ "browser.search.withads.tabhistory": {
+ "example1:tagged": 1,
+ },
+ "browser.search.adclicks.tabhistory": {
+ "example1:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ // Tab 1 - Visit a SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a SERP, click a related SERP with ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a SERP, click back button.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ // Tab 2 - Visit a SERP, click ad button.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+});
+
+add_task(async function test_different_sources_click_redirect_ad_in_new_tab() {
+ resetTelemetry();
+
+ let tab1 = await SinglePageAppUtils.createTabAndLoadURL();
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL();
+
+ await SinglePageAppUtils.visitRelatedSearch(tab2);
+ await SinglePageAppUtils.goBack(tab2);
+ let tab3 = await SinglePageAppUtils.clickRedirectAdInNewTab(tab2);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 3,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 3,
+ },
+ "browser.search.content.tabhistory": {
+ "example1:tagged:ff": 1,
+ },
+ "browser.search.withads.tabhistory": {
+ "example1:tagged": 1,
+ },
+ "browser.search.adclicks.tabhistory": {
+ "example1:tagged": 1,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ // Tab 1 - Visit a SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a SERP, click a related SERP with ads.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a SERP, click back button.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ // Tab 2 - Visit a SERP, click ad button.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+ await BrowserTestUtils.removeTab(tab3);
+});
+
+add_task(async function test_update_query_params_after_search() {
+ resetTelemetry();
+
+ let tab1 = await SinglePageAppUtils.createTabAndLoadURL();
+ info("Visit a related search so that the URL has an extra query parameter.");
+ await SinglePageAppUtils.visitRelatedSearch(tab1);
+
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 3,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 3,
+ },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ // Tab 1 - Visit a SERP, click on a related SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 1 - Visit a related SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a SERP, click on an ad.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+});
+
+add_task(async function test_update_query_params() {
+ resetTelemetry();
+
+ // Deliberately use a different search term for the first example, because
+ // if both tabs have the same search term and a link is clicked that opens a
+ // new window, we currently can't recover the exact browser.
+ let tab1 = await SinglePageAppUtils.createTabAndSearch("foo bar");
+ info("Visit a related search so that the URL has an extra query parameter.");
+ await SinglePageAppUtils.visitRelatedSearch(tab1);
+
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL();
+ let newWindow = await SinglePageAppUtils.clickRedirectAdInNewWindow(tab2);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 3,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 3,
+ },
+ "browser.search.adclicks.unknown": { "example1:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ // Tab 1 - Visit a SERP, clicked a related SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 1 - Visit a related SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a SERP, click ad opening in a new window.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.closeWindow(newWindow);
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+});
+
+add_task(async function test_update_query_params_multiple_related() {
+ resetTelemetry();
+
+ let tab1 = await SinglePageAppUtils.createTabAndSearch("foo bar");
+ info("Visit a related search so that the URL has an extra query parameter.");
+ await SinglePageAppUtils.visitRelatedSearch(tab1);
+
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL();
+ info("Visit a related search so that the URL has an extra query parameter.");
+ await SinglePageAppUtils.visitRelatedSearch(tab2);
+
+ let newWindow = await SinglePageAppUtils.clickRedirectAdInNewWindow(tab2);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": {
+ "example1:tagged:ff": 4,
+ },
+ "browser.search.withads.unknown": {
+ "example1:tagged": 4,
+ },
+ "browser.search.adclicks.unknown": { "example1:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ // Tab 1 - Visit a SERP, clicked a related SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 1 - Visit a related SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a SERP, clicked a related SERP.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ // Tab 2 - Visit a related SERP. Click on ad that opens in a new window.
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.closeWindow(newWindow);
+ await BrowserTestUtils.removeTab(tab1);
+ await BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js
new file mode 100644
index 0000000000..4f85c6cfa1
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js
@@ -0,0 +1,661 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * These tests check on SPA page loads in a single tab.
+ * They also ensure the SinglePageAppUtils method work as expected.
+ */
+
+"use strict";
+
+add_setup(async function () {
+ await initSinglePageAppTest();
+
+ registerCleanupFunction(async () => {
+ SearchSERPTelemetry.overrideSearchTelemetryForTests();
+ resetTelemetry();
+ });
+});
+
+add_task(async function test_load_serp() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_and_push_unrelated_state() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ let searchParams = new URL(tab.linkedBrowser.currentURI.spec).searchParams;
+
+ Assert.equal(
+ searchParams.get("foobar"),
+ null,
+ "Query param value for: foobar"
+ );
+
+ await SinglePageAppUtils.pushUnrelatedState(tab, {
+ key: "foobar",
+ value: "baz",
+ });
+ searchParams = new URL(tab.linkedBrowser.currentURI.spec).searchParams;
+ Assert.equal(
+ searchParams.get("foobar"),
+ "baz",
+ "Query param value for: foobar"
+ );
+
+ // If the SERP adds query parameter unrelated to the search query or the
+ // query param matching the default results page, we shouldn't record another
+ // SERP load.
+ /* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_and_load_non_serp_tab() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+
+ await SinglePageAppUtils.clickImagesTab(tab);
+ // If clicking another tab in a SPA, we shouldn't record another SERP load.
+ /* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_and_click_ad() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await SinglePageAppUtils.clickAd(tab);
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ "browser.search.adclicks.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_and_click_redirect_ad() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+
+ await SinglePageAppUtils.clickRedirectAd(tab);
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ "browser.search.adclicks.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_and_click_redirect_ad_in_new_tab() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+
+ let redirectedTab = await SinglePageAppUtils.clickRedirectAdInNewTab(tab);
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ "browser.search.adclicks.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.removeTab(redirectedTab);
+});
+
+add_task(async function test_load_serp_click_a_related_search() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.visitRelatedSearch(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 2 },
+ "browser.search.withads.unknown": { "example1:tagged": 2 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_click_a_related_search_click_ad() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.visitRelatedSearch(tab);
+ await SinglePageAppUtils.clickAd(tab);
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 2 },
+ "browser.search.withads.unknown": { "example1:tagged": 2 },
+ "browser.search.adclicks.unknown": { "example1:tagged": 1 },
+ }
+ );
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_click_non_serp_tab_click_all() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.clickImagesTab(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 1 },
+ "browser.search.withads.unknown": { "example1:tagged": 1 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ info("Click All tab to return to a SERP.");
+ await SinglePageAppUtils.clickAllTab(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 2 },
+ "browser.search.withads.unknown": { "example1:tagged": 2 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_load_serp_and_use_back_and_forward() {
+ resetTelemetry();
+ let tab = await SinglePageAppUtils.createTabAndLoadURL();
+ await SinglePageAppUtils.visitRelatedSearch(tab);
+ await SinglePageAppUtils.goBack(tab);
+ await SinglePageAppUtils.goForward(tab);
+
+ await assertSearchSourcesTelemetry(
+ {},
+ {
+ "browser.search.content.unknown": { "example1:tagged:ff": 2 },
+ "browser.search.withads.unknown": { "example1:tagged": 2 },
+ "browser.search.content.tabhistory": { "example1:tagged:ff": 2 },
+ "browser.search.withads.tabhistory": { "example1:tagged": 2 },
+ }
+ );
+
+ assertSERPTelemetry([
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ engagements: [
+ {
+ action: SearchSERPTelemetryUtils.ACTIONS.CLICKED,
+ target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK,
+ },
+ ],
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "unknown",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ abandonment: {
+ reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION,
+ },
+ },
+ {
+ impression: {
+ provider: "example1",
+ tagged: "true",
+ partner_code: "ff",
+ source: "tabhistory",
+ is_shopping_page: "false",
+ is_private: "false",
+ shopping_tab_displayed: "false",
+ },
+ adImpressions: [
+ {
+ component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ ads_loaded: "2",
+ ads_visible: "2",
+ ads_hidden: "0",
+ },
+ ],
+ },
+ ]);
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/components/search/test/browser/telemetry/cacheable.html b/browser/components/search/test/browser/telemetry/cacheable.html
new file mode 100644
index 0000000000..8aac4a0f16
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/cacheable.html^headers^ b/browser/components/search/test/browser/telemetry/cacheable.html^headers^
new file mode 100644
index 0000000000..6f34caa8f2
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/cacheable.html^headers^
@@ -0,0 +1 @@
+Cache-Control: max-age=3600
diff --git a/browser/components/search/test/browser/telemetry/domain_category_mappings.json b/browser/components/search/test/browser/telemetry/domain_category_mappings.json
new file mode 100644
index 0000000000..2f8d0d2af2
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/domain_category_mappings.json
@@ -0,0 +1,8 @@
+{
+ "DqNorjpE3CBY9OZh0wf1uA==": [2, 90],
+ "kpuib0kvhtSp1moICEmGWg==": [2, 95],
+ "+5WbbjV3Nmxp0mBZODcJWg==": [2, 78, 4, 10],
+ "OIHlWZ/yMyTHHuY78AV9VQ==": [3, 90],
+ "r1hDZinn+oNrQjabn8IB9w==": [4, 90],
+ "AtlIam7nqWvzFzTGkYI01w==": [4, 90]
+}
diff --git a/browser/components/search/test/browser/telemetry/head-spa.js b/browser/components/search/test/browser/telemetry/head-spa.js
new file mode 100644
index 0000000000..2718dbb9ff
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/head-spa.js
@@ -0,0 +1,259 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Helpers to simulate the use of a single page application.
+ */
+
+/* import-globals-from head.js */
+
+/**
+ * Used to control the SPA SERP.
+ */
+class SinglePageAppUtils {
+ static async clickAd(tab) {
+ info("Clicking ad.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad",
+ {},
+ tab.linkedBrowser
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ }
+
+ static async clickAllTab(tab) {
+ info("Click All tab to return to a SERP.");
+ let adsPromise = TestUtils.topicObserved(
+ "reported-page-with-ad-impressions"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#all",
+ {},
+ tab.linkedBrowser
+ );
+ await adsPromise;
+ }
+
+ static async clickImagesTab(tab) {
+ info("Click images tab.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#images",
+ {},
+ tab.linkedBrowser
+ );
+ info("Wait a brief amount of time.");
+ // There's no obvious way to know we shouldn't expect a SERP impression, so
+ // we wait roughly the amount of time it would take for extracting ads to
+ // take.
+ await promiseWaitForAdLinkCheck();
+ }
+
+ static async clickOrganic(tab) {
+ info("Clicking organic result.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#organic",
+ {},
+ tab.linkedBrowser
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ }
+
+ static async clickRedirectAd(tab) {
+ info("Clicking redirect ad.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad-redirect",
+ {},
+ tab.linkedBrowser
+ );
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ }
+
+ static async clickRedirectAdInNewTab(tab) {
+ info("Clicking redirect ad in new tab.");
+ let tabPromise = BrowserTestUtils.waitForNewTab(tab.ownerGlobal.gBrowser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad-redirect",
+ { button: 1 },
+ tab.linkedBrowser
+ );
+ let redirectedTab = await tabPromise;
+ return redirectedTab;
+ }
+
+ static async clickRedirectAdInNewWindow(tab) {
+ let contextMenu = tab.linkedBrowser.ownerGlobal.document.getElementById(
+ "contentAreaContextMenu"
+ );
+ let contextMenuPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ info("Open context menu.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#ad-redirect",
+ { type: "contextmenu", button: 2 },
+ tab.linkedBrowser
+ );
+ await contextMenuPromise;
+
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: "https://example.com/hello_world",
+ });
+ let openLinkInNewWindow = contextMenu.querySelector("#context-openlink");
+ info("Click on Open Link in New Window");
+ contextMenu.activateItem(openLinkInNewWindow);
+ return await newWindowPromise;
+ }
+
+ static async clickSearchbox(tab) {
+ info("Clicking searchbox.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#searchbox",
+ {},
+ tab.linkedBrowser
+ );
+ await waitForIdle();
+ }
+
+ static async clickSearchboxAndType(tab, str = "hello world") {
+ await SinglePageAppUtils.clickSearchbox(tab);
+ info(`Type ${str} in searchbox.`);
+ for (let char of str) {
+ await BrowserTestUtils.sendChar(char, tab.linkedBrowser);
+ }
+ await waitForIdle();
+ }
+
+ static async clickSuggestion(tab) {
+ info("Clicking the first suggestion.");
+ let adsPromise = TestUtils.topicObserved(
+ "reported-page-with-ad-impressions"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#searchbox-suggestions div",
+ {},
+ tab.linkedBrowser
+ );
+ await adsPromise;
+ }
+
+ static async clickSuggestionOnImagesTab(tab) {
+ info("Clicking the first suggestion on images tab.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#searchbox-suggestions div",
+ {},
+ tab.linkedBrowser
+ );
+ await promiseWaitForAdLinkCheck();
+ }
+
+ static async createTabAndLoadURL(
+ url = new URL(getSERPUrl("searchTelemetrySinglePageApp.html"))
+ ) {
+ info("Load SERP.");
+ let adsPromise = TestUtils.topicObserved(
+ "reported-page-with-ad-impressions"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url.href);
+ await adsPromise;
+ return tab;
+ }
+
+ static async createTabAndSearch(searchTerm = "test") {
+ info("Load SERP.");
+ let adsPromise = TestUtils.topicObserved(
+ "reported-page-with-ad-impressions"
+ );
+ let url = new URL(getSERPUrl("searchTelemetrySinglePageApp.html"));
+ url.searchParams.set("s", searchTerm);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url.href);
+ await adsPromise;
+ return tab;
+ }
+
+ static async createTabsWithDifferentProviders() {
+ let url1 = new URL(getSERPUrl("searchTelemetrySinglePageApp.html"));
+ let tab1 = await SinglePageAppUtils.createTabAndLoadURL(url1);
+
+ let url2 = new URL(
+ getAlternateSERPUrl("searchTelemetrySinglePageApp.html")
+ );
+ let tab2 = await SinglePageAppUtils.createTabAndLoadURL(url2);
+
+ return [tab1, tab2];
+ }
+
+ static async goBack(tab) {
+ info("Go back to SERP ads.");
+ let promise = TestUtils.topicObserved("reported-page-with-ad-impressions");
+ tab.linkedBrowser.goBack();
+ await promise;
+ }
+
+ static async goBackToPageWithoutAds(tab) {
+ info("Go back to SERP without ads.");
+ tab.linkedBrowser.goBack();
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+
+ static async goForward(tab) {
+ info("Go forward to SERP ads.");
+ let promise = TestUtils.topicObserved("reported-page-with-ad-impressions");
+ tab.linkedBrowser.goForward();
+ await promise;
+ }
+
+ static async goForwardToPageWithoutAds(tab) {
+ info("Go forward to SERP without ads.");
+ tab.linkedBrowser.goForward();
+ await new Promise(resolve => setTimeout(resolve, 200));
+ }
+
+ static async pushUnrelatedState(tab, { key = "foobar", value = "baz" } = {}) {
+ info(`Pushing ${key}=${value} to the list of query parameters in URL.`);
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [key, value],
+ async function (contentKey, contentValue) {
+ let url = new URL(content.window.location.href);
+ url.searchParams.set(contentKey, contentValue);
+ content.history.pushState({}, "", url);
+ }
+ );
+ }
+
+ static async visitRelatedSearch(tab) {
+ let adsPromise = TestUtils.topicObserved(
+ "reported-page-with-ad-impressions"
+ );
+ info("Clicking a related search with an ad.");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#related-search",
+ {},
+ tab.linkedBrowser
+ );
+ await adsPromise;
+ }
+
+ static async visitRelatedSearchWithoutAds(tab) {
+ info("Clicking a related search without ads.");
+ let adsPromise = TestUtils.topicObserved(
+ "reported-page-with-ad-impressions"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#related-search-without-ads",
+ {},
+ tab.linkedBrowser
+ );
+ await adsPromise;
+ }
+}
+
+function getAlternateSERPUrl(page, organic = false) {
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + page;
+ return `${url}?s=test${organic ? "" : "&abc=ff"}`;
+}
diff --git a/browser/components/search/test/browser/telemetry/head.js b/browser/components/search/test/browser/telemetry/head.js
new file mode 100644
index 0000000000..416451e400
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/head.js
@@ -0,0 +1,621 @@
+/* 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",
+ CustomizableUITestUtils:
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs",
+ Region: "resource://gre/modules/Region.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SEARCH_TELEMETRY_SHARED: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ SERPCategorizationRecorder: "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+ SPA_ADLINK_CHECK_TIMEOUT_MS:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ TELEMETRY_CATEGORIZATION_KEY:
+ "resource:///modules/SearchSERPTelemetry.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => {
+ const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlbarTestUtils.sys.mjs"
+ );
+ module.init(this);
+ return module;
+});
+
+ChromeUtils.defineLazyGetter(this, "searchCounts", () => {
+ return Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS");
+});
+
+ChromeUtils.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}`),
+ ];
+});
+
+// For use with categorization.
+const APP_MAJOR_VERSION = parseInt(Services.appinfo.version).toString();
+const CHANNEL = SearchUtils.MODIFIED_APP_CHANNEL;
+const REGION = Region.home;
+
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+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;
+
+// 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 getPageUrl(useAdPage = false) {
+ let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html";
+ return `https://example.org/browser/browser/components/search/test/browser/telemetry/${page}`;
+}
+
+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 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;
+ }
+ );
+}
+
+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;
+}
+
+// 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`
+ );
+ }
+}
+
+function resetTelemetry() {
+ // TODO Bug 1868476: Replace when we're using Glean telemetry.
+ fakeTelemetryStorage = [];
+ 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 assertSERPTelemetry(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,
+ "Number of impressions matches expected events."
+ );
+
+ // Assert the impression events.
+ for (let [idx, expectedEvent] of expectedEvents.entries()) {
+ let impressionId = recordedImpressions[idx].extra.impression_id;
+ Assert.ok(
+ UUID_REGEX.test(impressionId),
+ "Impression has an impression_id with a valid UUID."
+ );
+
+ Assert.ok(
+ !impressionIdsSet.has(impressionId),
+ "Impression has 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,
+ "Matching SERP impression values."
+ );
+
+ // Once the impression check is sufficient, add the impression_id to
+ // each of the expected engagements, ad impressions, and abandonments for
+ // deep equal checks.
+ if (expectedEvent.engagements) {
+ for (let expectedEngagment of expectedEvent.engagements) {
+ expectedEngagment.impression_id = impressionId;
+ }
+ }
+ if (expectedEvent.adImpressions) {
+ for (let adImpression of expectedEvent.adImpressions) {
+ adImpression.impression_id = impressionId;
+ }
+ }
+ if (expectedEvent.abandonment) {
+ expectedEvent.abandonment.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, "Engagement event has 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,
+ "Matching engagement value."
+ );
+ totalExpectedEngagements += expectedEngagements.length;
+ }
+ }
+
+ Assert.equal(
+ recordedEngagements.length,
+ totalExpectedEngagements,
+ "Number of engagements"
+ );
+
+ let recordedAdImpressions = Glean.serp.adImpression.testGetValue() ?? [];
+ let idToAdImpressions = new Map();
+ let totalExpectedAdImpressions = 0;
+
+ // The list of ad impressions are contained in a flat list. Separate them
+ // into arrays organized by impressionId to make it easier to determine if
+ // the page load that matches the expected ads on the page.
+ for (let recordedAdImpression of recordedAdImpressions) {
+ let impressionId = recordedAdImpression.extra.impression_id;
+ Assert.ok(impressionId, "Ad impression has impression_id");
+
+ let arr = idToAdImpressions.get(impressionId) ?? [];
+ arr.push(recordedAdImpression.extra);
+ idToAdImpressions.set(impressionId, arr);
+ }
+
+ for (let expectedEvent of expectedEvents) {
+ let impressionId = expectedEvent.impression.impression_id;
+ let expectedAdImpressions = expectedEvent.adImpressions ?? [];
+ if (expectedAdImpressions.length) {
+ let recorded = idToAdImpressions.get(impressionId) ?? {};
+ Assert.deepEqual(
+ recorded,
+ expectedAdImpressions,
+ "Matching ad impression value."
+ );
+ }
+ totalExpectedAdImpressions += expectedAdImpressions.length;
+ }
+
+ Assert.equal(
+ recordedAdImpressions.length,
+ totalExpectedAdImpressions,
+ "Recorded and expected ad impression counts match."
+ );
+
+ // Assert abandonment events.
+ let recordedAbandonments = Glean.serp.abandonment.testGetValue() ?? [];
+ let idTorecordedAbandonments = new Map();
+ let totalExpectedrecordedAbandonments = 0;
+
+ for (let recordedAbandonment of recordedAbandonments) {
+ let impressionId = recordedAbandonment.extra.impression_id;
+ Assert.ok(impressionId, "Abandonment event has an impression_id.");
+ idTorecordedAbandonments.set(impressionId, recordedAbandonment.extra);
+ }
+
+ for (let expectedEvent of expectedEvents) {
+ let impressionId = expectedEvent.impression.impression_id;
+ let expectedAbandonment = expectedEvent.abandonment;
+ if (expectedAbandonment) {
+ let recorded = idTorecordedAbandonments.get(impressionId);
+ Assert.deepEqual(
+ recorded,
+ expectedAbandonment,
+ "Matching abandonment value."
+ );
+ }
+ totalExpectedrecordedAbandonments += expectedAbandonment ? 1 : 0;
+ }
+
+ Assert.equal(
+ recordedAbandonments.length,
+ totalExpectedrecordedAbandonments,
+ "Recorded and expected abandonment counts match."
+ );
+}
+
+// TODO Bug 1868476: Replace when we're using Glean telemetry.
+let categorizationSandbox;
+let fakeTelemetryStorage = [];
+add_setup(function () {
+ categorizationSandbox = sinon.createSandbox();
+ categorizationSandbox
+ .stub(SERPCategorizationRecorder, "recordCategorizationTelemetry")
+ .callsFake(input => {
+ fakeTelemetryStorage.push(input);
+ });
+
+ registerCleanupFunction(() => {
+ categorizationSandbox.restore();
+ fakeTelemetryStorage = [];
+ });
+});
+
+function assertCategorizationValues(expectedResults) {
+ // TODO Bug 1868476: Replace with calls to Glean telemetry.
+ let actualResults = [...fakeTelemetryStorage];
+
+ Assert.equal(
+ expectedResults.length,
+ actualResults.length,
+ "Should have the correct number of categorization impressions."
+ );
+
+ if (!expectedResults.length) {
+ return;
+ }
+
+ // We use keys in the result vs. Assert.deepEqual to make it easier to
+ // identify exact discrepancies in comparisons, because it can be tedious to
+ // parse a giant list of values.
+ let keys = new Set();
+ for (let expected of expectedResults) {
+ for (let key in expected) {
+ keys.add(key);
+ }
+ }
+ for (let actual of actualResults) {
+ for (let key in actual) {
+ keys.add(key);
+ }
+ }
+ keys = Array.from(keys);
+
+ for (let index = 0; index < expectedResults.length; ++index) {
+ info(`Checking categorization at index: ${index}`);
+ let expected = expectedResults[index];
+ let actual = actualResults[index];
+ for (let key of keys) {
+ // TODO Bug 1868476: This conversion to strings is to mimic Glean
+ // converting all values into strings. Once we receive real values from
+ // Glean, it can be removed.
+ if (actual[key] != null && typeof actual[key] !== "string") {
+ actual[key] = actual[key].toString();
+ }
+ Assert.equal(
+ actual[key],
+ expected[key],
+ `Actual and expected values for ${key} should match.`
+ );
+ }
+ }
+}
+
+function waitForPageWithAdImpressions() {
+ return TestUtils.topicObserved("reported-page-with-ad-impressions");
+}
+
+function waitForPageWithCategorizedDomains() {
+ return TestUtils.topicObserved("reported-page-with-categorized-domains");
+}
+
+function waitForSingleCategorizedEvent() {
+ return TestUtils.topicObserved("recorded-single-categorization-event");
+}
+
+function waitForAllCategorizedEvents() {
+ return TestUtils.topicObserved("recorded-all-categorization-events");
+}
+
+function waitForDomainToCategoriesUpdate() {
+ return TestUtils.topicObserved("domain-to-categories-map-update-complete");
+}
+
+registerCleanupFunction(async () => {
+ await PlacesUtils.history.clear();
+});
+
+async function mockRecordWithAttachment({ id, version, filename }) {
+ // Get the bytes of the file for the hash and size for attachment metadata.
+ let data = await IOUtils.readUTF8(getTestFilePath(filename));
+ let buffer = new TextEncoder().encode(data).buffer;
+ let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(
+ Ci.nsIArrayBufferInputStream
+ );
+ stream.setData(buffer, 0, buffer.byteLength);
+
+ // Generate a hash.
+ let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ hasher.init(Ci.nsICryptoHash.SHA256);
+ hasher.updateFromStream(stream, -1);
+ let hash = hasher.finish(false);
+ hash = Array.from(hash, (_, i) =>
+ ("0" + hash.charCodeAt(i).toString(16)).slice(-2)
+ ).join("");
+
+ let record = {
+ id,
+ version,
+ attachment: {
+ hash,
+ location: `main-workspace/search-categorization/${filename}`,
+ filename,
+ size: buffer.byteLength,
+ mimetype: "application/json",
+ },
+ };
+
+ let attachment = {
+ record,
+ blob: new Blob([buffer]),
+ };
+
+ return { record, attachment };
+}
+
+async function resetCategorizationCollection(record) {
+ const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY);
+ await client.attachments.cacheImpl.delete(record.id);
+ await client.db.clear();
+ await client.db.importChanges({}, Date.now());
+}
+
+async function insertRecordIntoCollection() {
+ const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY);
+ const db = client.db;
+
+ await db.clear();
+ let { record, attachment } = await mockRecordWithAttachment({
+ id: "example_id",
+ version: 1,
+ filename: "domain_category_mappings.json",
+ });
+ await db.create(record);
+ await client.attachments.cacheImpl.set(record.id, attachment);
+ await db.importChanges({}, Date.now());
+
+ return { record, attachment };
+}
+
+async function insertRecordIntoCollectionAndSync() {
+ let { record } = await insertRecordIntoCollection();
+
+ registerCleanupFunction(async () => {
+ await resetCategorizationCollection(record);
+ });
+
+ await syncCollection(record);
+}
+
+async function syncCollection(record) {
+ let arrayWithRecord = record ? [record] : [];
+ await RemoteSettings(TELEMETRY_CATEGORIZATION_KEY).emit("sync", {
+ data: {
+ current: arrayWithRecord,
+ created: arrayWithRecord,
+ updated: [],
+ deleted: [],
+ },
+ });
+}
+
+async function initSinglePageAppTest() {
+ /* import-globals-from head-spa.js */
+ Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/components/search/test/browser/telemetry/head-spa.js",
+ this
+ );
+
+ const BASE_PROVIDER = {
+ telemetryId: "example1",
+ searchPageRegexp:
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetrySinglePageApp/,
+ queryParamNames: ["s"],
+ codeParamName: "abc",
+ taggedCodes: ["ff"],
+ adServerAttributes: ["mozAttr"],
+ extraAdServersRegexps: [
+ /^https:\/\/example\.com\/ad/,
+ /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/redirect_ad/,
+ ],
+ components: [
+ {
+ included: {
+ parent: {
+ selector: "#searchbox-container",
+ },
+ related: {
+ selector: "#searchbox-suggestions",
+ },
+ children: [
+ {
+ selector: "#searchbox",
+ },
+ ],
+ },
+ topDown: true,
+ type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX,
+ },
+ {
+ type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK,
+ default: true,
+ },
+ ],
+ isSPA: true,
+ defaultPageQueryParam: {
+ key: "page",
+ value: "web",
+ },
+ };
+
+ const SPA_PROVIDER_INFO = [
+ BASE_PROVIDER,
+ {
+ ...BASE_PROVIDER,
+ telemetryId: "example2",
+ // Use example.com instead of example.org so that we have two providers
+ // with different TLD's and won't share the same web process.
+ searchPageRegexp:
+ /^https:\/\/example.com\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetrySinglePageApp/,
+ },
+ ];
+
+ SearchSERPTelemetry.overrideSearchTelemetryForTests(SPA_PROVIDER_INFO);
+ await waitForIdle();
+
+ // Shorten delay to avoid potential TV timeouts.
+ Services.ppmm.sharedData.set(SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT, 100);
+
+ registerCleanupFunction(function () {
+ Services.ppmm.sharedData.set(
+ SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT,
+ SPA_ADLINK_CHECK_TIMEOUT_MS
+ );
+ });
+}
diff --git a/browser/components/search/test/browser/telemetry/redirect_ad.sjs b/browser/components/search/test/browser/telemetry/redirect_ad.sjs
new file mode 100644
index 0000000000..36be567d3f
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/redirect_final.sjs b/browser/components/search/test/browser/telemetry/redirect_final.sjs
new file mode 100644
index 0000000000..14debde6ba
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/redirect_once.sjs b/browser/components/search/test/browser/telemetry/redirect_once.sjs
new file mode 100644
index 0000000000..d15f3afe6d
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/redirect_thrice.sjs b/browser/components/search/test/browser/telemetry/redirect_thrice.sjs
new file mode 100644
index 0000000000..b7c7069162
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/redirect_twice.sjs b/browser/components/search/test/browser/telemetry/redirect_twice.sjs
new file mode 100644
index 0000000000..099d20022e
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetry.html b/browser/components/search/test/browser/telemetry/searchTelemetry.html
new file mode 100644
index 0000000000..bd395d4a7c
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd.html
new file mode 100644
index 0000000000..23d51d2fb5
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_components_carousel.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel.html
new file mode 100644
index 0000000000..71049be20c
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html
new file mode 100644
index 0000000000..737e1e654b
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_components_carousel_doubled.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html
new file mode 100644
index 0000000000..f7b7f948d9
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html
new file mode 100644
index 0000000000..b5a44b325e
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_components_carousel_hidden.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html
new file mode 100644
index 0000000000..cccd714326
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_components_carousel_outer_container.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html
new file mode 100644
index 0000000000..759bd9f0d9
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_components_query_parameters.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html
new file mode 100644
index 0000000000..7985fb2c51
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html
@@ -0,0 +1,36 @@
+<!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>
+ <!--
+ Note that the query parameter keys are in reverse alphabetical order
+ that will be reversed in the tests.
+ -->
+ <a id="ad_sitelink" href="https://example.com/ad?foo=bar0&baz=bar0">
+ <h2>Example Result</h2>
+ </a>
+ <div class="multi-col">
+ <div>
+ <a href="https://example.com/ad?foo=bar1&baz=bar1">
+ <h2>New Releases</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" href="https://example.com/ad?foo=bar2&baz=bar2">
+ <h2>Example Result</h2>
+ </a>
+ </div>
+ </div>
+ </section>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html
new file mode 100644
index 0000000000..66f056fb25
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/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/telemetry/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/telemetry/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/telemetry/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/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html";
+ });
+ </script>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html
new file mode 100644
index 0000000000..475ada3a3c
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_dataAttributes.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html
new file mode 100644
index 0000000000..7bc1b2745e
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_dataAttributes_href.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html
new file mode 100644
index 0000000000..319485cfae
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_dataAttributes_none.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html
new file mode 100644
index 0000000000..a119cf71be
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_nonAdsLink_redirect.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html
new file mode 100644
index 0000000000..d987356d7e
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^
new file mode 100644
index 0000000000..94cde2a288
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^
@@ -0,0 +1 @@
+Cache-Control: no-cache, must-revalidate
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html
new file mode 100644
index 0000000000..1c5c31cb38
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^
new file mode 100644
index 0000000000..419697b050
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_searchbox.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html
new file mode 100644
index 0000000000..7ba3f84f6b
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_searchbox.html?s=test+suggest&abc=ff";
+ })
+ document.getElementById("suggest").addEventListener("click", event => {
+ event.preventDefault();
+ window.location.href = "https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html?s=test+suggest&abc=ff";
+ })
+</script>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^
new file mode 100644
index 0000000000..62847d0585
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^
@@ -0,0 +1 @@
+Cache-Control: private, max-age=0
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html
new file mode 100644
index 0000000000..9c4d371691
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test&page=images">Images</a>
+ <a id="shopping" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test&page=shopping">Shopping</a>
+ <a id="extra" href="https://example.org/browser/browser/components/search/test/browser/telemetry/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/telemetry/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/telemetry/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/telemetry/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/telemetry/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/telemetry/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/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test&abc=ff";
+ });
+</script>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^
new file mode 100644
index 0000000000..94cde2a288
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^
@@ -0,0 +1 @@
+Cache-Control: no-cache, must-revalidate
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html
new file mode 100644
index 0000000000..c8a3245446
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/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/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^
new file mode 100644
index 0000000000..94cde2a288
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^
@@ -0,0 +1 @@
+Cache-Control: no-cache, must-revalidate
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html
new file mode 100644
index 0000000000..faa6c057a4
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/searchTelemetryDomainCategorization.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html
new file mode 100644
index 0000000000..b9569ba2d6
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Document</title>
+</head>
+<body>
+ <div id="results">
+ <!-- Don't include domains matching the provider. -->
+ <div class="organic">
+ <a href="https://www.example.com"></a>
+ <a href="https://example.com"></a>
+ </div>
+ <div class="organic">
+ <a href="https://www.foobar.org"></a>
+ </div>
+ <div data-ad-domain="abc.org">
+ <a href="https://www.example.com/"></a>
+ </div>
+ <div>
+ <a class="ad" href="https://www.example.com/?ad_domain=def.org"></a>
+ </div>
+ <!-- Don't throw on anchors with non-standard or non-existent hrefs -->
+ <div>
+ <a href="javascript:console.log('hello world')">A javascript: URL link</a>
+ </div>
+ <div>
+ <a>An anchor that's missing an href attribute</a>
+ </div>
+ <div>
+ <a href="#">An anchor with a dummy href attribute value</a>
+ </div>
+ </div>
+ <aside>
+ <div class="organic">
+ <a href="https://foobaz.com"></a>
+ </div>
+ </aside>
+ <div class="organic">
+ <!-- Should not find this because it's not part of the results -->
+ <a href="https://outside-results.ca"></a>
+ </div>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html
new file mode 100644
index 0000000000..63a44b8e77
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Document</title>
+</head>
+<body>
+ <div id="results">
+ <div class="organic">
+ <a href="https://www.test1.com">Organic Link 1</a>
+ <a href="https://www.test2.com">Organic Link 2</a>
+ <a href="https://www.test3.com">Organic Link 3</a>
+ <a href="https://www.test4.com">Organic Link 4</a>
+ <a href="https://www.test5.com">Organic Link 5</a>
+ <a href="https://www.test6.com">Organic Link 6</a>
+ <a href="https://www.test7.com">Organic Link 7</a>
+ <a href="https://www.test8.com">Organic Link 8</a>
+ <a href="https://www.test9.com">Organic Link 9</a>
+ <a href="https://www.test10.com">Organic Link 10</a>
+ <a href="https://www.test11.com">Organic Link 11</a>
+ <a href="https://www.test12.com">Organic Link 12</a>
+ </div>
+
+ <div class="ad">
+ <div data-ad-domain="foo.com">
+ <a href="https://www.test13.com/">Non-organic Link 1</a>
+ </div>
+ <div data-ad-domain="bar.com">
+ <a href="https://www.test14.com/">Non-organic Link 2</a>
+ </div>
+ <div data-ad-domain="baz.com">
+ <a href="https://www.test15.com/">Non-organic Link 3</a>
+ </div>
+ <div data-ad-domain="qux.com">
+ <a href="https://www.test16.com/">Non-organic Link 4</a>
+ </div>
+ <div data-ad-domain="abc.com">
+ <a href="https://www.test17.com/">Non-organic Link 5</a>
+ </div>
+ <div data-ad-domain="def.com">
+ <a href="https://www.test18.com/">Non-organic Link 6</a>
+ </div>
+ <div>
+ <a class="ad" href="https://www.test19.com/?ad_domain=ghi.org">Non-organic Link 7</a>
+ </div>
+ <div>
+ <a class="ad" href="https://www.test20.com/?ad_domain=jkl.org">Non-organic Link 8</a>
+ </div>
+ <div>
+ <a class="ad" href="https://www.test21.com/?ad_domain=mno.org">Non-organic Link 9</a>
+ </div>
+ <div>
+ <a class="ad" href="https://www.test22.com/?ad_domain=pqr.org">Non-organic Link 10</a>
+ </div>
+ <div>
+ <a class="ad" href="https://www.test23.com/?ad_domain=stu.org">Non-organic Link 11</a>
+ </div>
+ <div>
+ <a class="ad" href="https://www.test24.com/?ad_domain=vwx.org">Non-organic Link 12</a>
+ </div>
+ </div>
+ </div>
+</body>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html
new file mode 100644
index 0000000000..22f763191a
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html
@@ -0,0 +1,45 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Document</title>
+</head>
+<body>
+ <div id="results">
+ <!-- Don't include domains matching the provider. -->
+ <div class="organic">
+ <a href="https://www.example.com">Link</a>
+ <a href="https://example.com">Link</a>
+ </div>
+ <div class="organic">
+ <a href="https://www.foobar.org">Link</a>
+ </div>
+ <div data-ad-domain="abc.org">
+ <a href="https://example.com/ad">Sponsored Link</a>
+ </div>
+ <div>
+ <a class="ad" href="https://example.com/ad?ad_domain=def.org">Sponsored Link</a>
+ </div>
+ <!-- Don't throw on anchors with non-standard or non-existent hrefs -->
+ <div>
+ <a href="javascript:console.log('hello world')">A javascript: URL link</a>
+ </div>
+ <div>
+ <a>An anchor that's missing an href attribute</a>
+ </div>
+ <div>
+ <a href="#">An anchor with a dummy href attribute value</a>
+ </div>
+ </div>
+ <aside>
+ <div class="organic">
+ <a href="https://foobaz.com"></a>
+ </div>
+ </aside>
+ <div class="organic">
+ <!-- Should not find this because it's not part of the results -->
+ <a href="https://outside-results.ca"></a>
+ </div>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html
new file mode 100644
index 0000000000..b49e5610ae
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Document</title>
+</head>
+<body>
+ <div id="results">
+ <div id="test1">
+ <div data-layout="organic">
+ <a href="https://foobar.com" data-testid="result-title-a">Extract domain from href (absolute URL).</a>
+ </div>
+ </div>
+
+ <div id="test2">
+ <div data-layout="organic">
+ <a href="https://foo.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link1.</a>
+ <a href="https://bar.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link2.</a>
+ <a href="https://baz.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link3.</a>
+ <a href="https://qux.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link4.</a>
+ </div>
+ </div>
+
+ <div id="test3">
+ <div data-layout="organic">
+ <a href="/dummy-page" data-testid="result-title-a">Extract domain from href (relative URL).</a>
+ </div>
+ </div>
+
+ <div id="test4">
+ <a href="#" data-dtld="www.abc.com">Extract domain from data attribute.</a>
+ </div>
+
+ <div id="test5">
+ <a href="#" data-dtld="www.foo.com">Extract domain from data attribute - link1.</a>
+ <a href="#" data-dtld="www.bar.com">Extract domain from data attribute - link2.</a>
+ <a href="#" data-dtld="www.baz.com">Extract domain from data attribute - link3.</a>
+ <a href="#" data-dtld="www.qux.com">Extract domain from data attribute - link4.</a>
+ </div>
+
+ <div id="test6">
+ <a href="example.com/testing?ad_domain=def.com" class="js-carousel-item-title">Extract domain from an href's query param value.</a>
+ </div>
+
+ <div id="test7">
+ <a href="https://example.com/test?ad_domain=https://def.com/path/to/nowhere">Extract domain from an href's query param value containing an absolute href.</a>
+ </div>
+
+ <div id="test8">
+ <a href="https://example.com/test?ad_domain=def.com/path/to/nowhere">Extract domain from an href's query param value containing a relative href.</a>
+ </div>
+
+ <div id="test9">
+ <a href="https://example.com/test?dummy_key=foo.com">Param value is missing from the href.</a>
+ </div>
+
+ <div id="test10">
+ <!-- Extraction preserves order of domains within the page. -->
+ <div data-layout="organic">
+ <a href="https://foobar.com" data-testid="result-title-a">Extract domain from href (absolute URL).</a>
+ <a href="#" data-dtld="www.abc.com">Extract domain from data attribute.</a>
+ <a href="example.com/testing?ad_domain=def.com" class="js-carousel-item-title">Extract domain from an href's query param value.</a>
+ </div>
+ </div>
+
+ <div id="test11">
+ <a href="nomatches.com">Link that doesn't match a selector.</a>
+ </div>
+
+ <div id="test12">
+ <a href="#" data-dtld="">Data attribute is present, but value is missing.</a>
+ </div>
+
+ <div id="test13">
+ <a href="example.com/testing?ad_domain=" class="js-carousel-item-title">Query param is present, but value is missing.</a>
+ </div>
+
+ <div id="test14">
+ <a href="git://testing.com/testrepo">Non-standard URL scheme.</a>
+ </div>
+ </div>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html b/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html
new file mode 100644
index 0000000000..7598da694e
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html
@@ -0,0 +1,243 @@
+<!DOCTYPE html>
+<!--
+ This SERP loads content dynamically. When a search term is provided in the
+ query parameter, it'll populate results using it.
+
+ Clicking images will load a fake results page with some ad links to ensure
+ we aren't tracking them again.
+
+ Searching "no-ads" will load a results page with no ads. This is so that if
+ there are multiple tabs open with a SERP and we tell an actor to look for an
+ ad, there shouldn't be any results.
+-->
+<html>
+<head>
+ <title>Fake SERP</title>
+ <meta charset="utf-8">
+</head>
+<body>
+ <nav style="display: flex; gap: 10px;">
+ <a id="all" data-menu="all">All</a>
+ <a id="images" data-menu="images">Images</a>
+ </nav>
+ <div id="searchbox-container">
+ <input id="searchbox" type="text" placeholder="search" />
+ <div id="searchbox-suggestions"></div>
+ </div>
+ <div style="margin: 10px 0;" id="results"></div>
+ <div id="related-searches"></div>
+ <script>
+ const allTab = document.querySelector("[data-menu='all']");
+ const imagesTab = document.querySelector("[data-menu='images']");
+ const results = document.getElementById("results");
+ const related = document.getElementById("related-searches");
+ const searchBox = document.getElementById("searchbox");
+ const suggestion = document.getElementById("searchbox-suggestions");
+ let searchKey = "s";
+
+ function getSearchTerm(){
+ let searchTerm = new URL(window.location.href).searchParams.get(searchKey);
+ if (!searchTerm) {
+ return "";
+ }
+ return { originalSearchTerm: searchTerm, searchTerm: searchTerm.replaceAll("+", " ") };
+ }
+
+ function replaceWithBasicResults() {
+ let { originalSearchTerm, searchTerm } = getSearchTerm();
+ let hasAds = !searchTerm.startsWith("no ads");
+ if (!searchTerm) {
+ return;
+ }
+ let result = `
+ <div>
+ <a id="organic" href="https://example.com/nonad+${originalSearchTerm}">
+ Non Ad Result - ${searchTerm}
+ </a>
+ </div>
+ `;
+ if (hasAds) {
+ result += `
+ <div>
+ <a id="ad" href="https://example.com/ad+${originalSearchTerm}">
+ Ad Result - ${searchTerm}
+ </a>
+ </div>
+ <div>
+ <a id="ad-redirect" href="https://example.org/browser/browser/components/search/test/browser/telemetry/redirect_ad.sjs">
+ Ad Result Redirect - ${searchTerm}
+ </a>
+ </div>
+ `;
+ }
+ results.innerHTML = result;
+ }
+
+ function replaceWithOtherResults() {
+ let { searchTerm } = getSearchTerm();
+ if (!searchTerm) {
+ return;
+ }
+ let result = `
+ <div style="width: 200px; height: 100px; background-color: #333;">
+ <a style="color: #FFF;"
+ href="https://example.com/otherpage">Non Ad Image - ${searchTerm}
+ </a>
+ </div>
+ <div style="width: 200px; height: 100px; background-color: #333;">
+ <a style="color: #FFF;"
+ href="https://example.com/ad">Ad Image - ${searchTerm}
+ </a>
+ </div>
+ `;
+ results.innerHTML = result;
+ }
+
+ function updateSearchbox() {
+ let { searchTerm } = getSearchTerm();
+ searchBox.value = searchTerm;
+ }
+
+ function updateSuggestions() {
+ let { searchTerm } = getSearchTerm();
+ let suggestions = `
+ <div id="first-suggestion" data-search="${searchTerm} suggestion">${searchTerm} suggestion</div>
+ `
+ suggestion.innerHTML = suggestions;
+ }
+
+ function updateNav() {
+ let baseUrl = new URL(window.location.href);
+
+ baseUrl.searchParams.set("page", "web");
+ allTab.href = baseUrl.href;
+
+ baseUrl.searchParams.set("page", "images");
+ imagesTab.href = baseUrl.href;
+ }
+
+ function updatePageState({ page = "", query = "" }) {
+ let url = new URL(window.location.href);
+ if (page) {
+ url.searchParams.set("page", page);
+ }
+ if (query) {
+ url.searchParams.set(searchKey, query);
+ }
+ history.pushState({}, "", url);
+ }
+
+ function updateRelatedSearches() {
+ let url = new URL(window.location.href);
+ let searchTerm = url.searchParams.get(searchKey);
+ let page = url.searchParams.get("page");
+
+ let innerResults = "";
+ if (searchTerm && page == "web") {
+ innerResults = `
+ <div>
+ <a id="related-search" data-search="how to ${searchTerm}" href="#">
+ how to ${searchTerm}
+ </a>
+ </div>
+ <div>
+ <a id="related-search-without-ads" data-search="no ads ${searchTerm}" href="#">
+ no ads related result for ${searchTerm}
+ </a>
+ </div>
+ `;
+ }
+ document.getElementById("related-searches").innerHTML = innerResults;
+ }
+
+ allTab.addEventListener("click", (event) => {
+ event.preventDefault();
+ updatePageState({ page: "web" });
+ replaceWithBasicResults();
+ updateRelatedSearches();
+ updateSearchbox();
+ updateSuggestions();
+ });
+
+ imagesTab.addEventListener("click", (event) => {
+ event.preventDefault();
+ updatePageState({ page: "images" });
+ replaceWithOtherResults();
+ updateRelatedSearches();
+ updateSearchbox();
+ updateSuggestions();
+ });
+
+ related.addEventListener("click", (event) => {
+ event.preventDefault();
+ let search = event.target.dataset.search;
+ if (search) {
+ updatePageState({ page: "web", query: search });
+ replaceWithBasicResults();
+ updateRelatedSearches();
+ updateNav();
+ updateSearchbox();
+ updateSuggestions();
+ }
+ });
+
+ suggestion.addEventListener("click", (event) => {
+ event.preventDefault();
+ let search = event.target.dataset.search;
+ let baseUrl = new URL(window.location.href);
+ let page = baseUrl.searchParams.get("page");
+ updatePageState({ page, query: search });
+ switch (page) {
+ case "web":
+ replaceWithBasicResults();
+ updateRelatedSearches();
+ updateNav();
+ updateSearchbox();
+ updateSuggestions();
+ break;
+ case "images":
+ replaceWithOtherResults();
+ updateRelatedSearches();
+ updateSearchbox();
+ updateSuggestions();
+ break;
+ }
+ })
+
+ window.addEventListener("DOMContentLoaded", (event) => {
+ let url = new URL(window.location.href);
+ searchKey = url.searchParams.has("r") ? "r": "s";
+
+ // When the page is loaded, we add a query parameter denoting the type
+ // of SERP this belongs to, mimicking how some SERPs operate.
+ url.searchParams.set("page", "web");
+ history.replaceState({}, "", url);
+ replaceWithBasicResults();
+ updateNav();
+ updateRelatedSearches();
+ updateSearchbox();
+ updateSuggestions();
+ });
+
+ window.addEventListener("popstate", (event) => {
+ let baseUrl = new URL(window.location.href);
+ let page = baseUrl.searchParams.get("page");
+ switch (page) {
+ case "web":
+ replaceWithBasicResults();
+ updateNav();
+ updateRelatedSearches();
+ updateSearchbox();
+ updateSuggestions();
+ break;
+ case "images":
+ replaceWithOtherResults();
+ updateRelatedSearches();
+ updateSearchbox();
+ break;
+ }
+ });
+
+ </script>
+</body>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/serp.css b/browser/components/search/test/browser/telemetry/serp.css
new file mode 100644
index 0000000000..5b3865da44
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/slow_loading_page_with_ads.html b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html
new file mode 100644
index 0000000000..8408066897
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/slow_loading_page_with_ads.sjs">
+</body>
+</html>
diff --git a/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs
new file mode 100644
index 0000000000..7a6382d1cb
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/slow_loading_page_with_ads_on_load_event.html b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html
new file mode 100644
index 0000000000..517dd30206
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/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/telemetry/telemetrySearchSuggestions.sjs b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs
new file mode 100644
index 0000000000..1978b4f665
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/telemetrySearchSuggestions.xml b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml
new file mode 100644
index 0000000000..4a3f6cdf33
--- /dev/null
+++ b/browser/components/search/test/browser/telemetry/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/telemetry/telemetrySearchSuggestions.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://example.com" rel="searchform"/>
+</SearchPlugin>