diff options
Diffstat (limited to 'browser/components/search/test/browser/telemetry')
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> |