diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/search/test | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/search/test')
118 files changed, 16526 insertions, 0 deletions
diff --git a/browser/components/search/test/browser/426329.xml b/browser/components/search/test/browser/426329.xml new file mode 100644 index 0000000000..b565ed7288 --- /dev/null +++ b/browser/components/search/test/browser/426329.xml @@ -0,0 +1,11 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>Bug 426329</ShortName> + <Description>426329 Search</Description> + <InputEncoding>utf-8</InputEncoding> + <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/test.html"> + <Param name="test" value="{searchTerms}"/> + </Url> + <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/test.html</moz:SearchForm> +</OpenSearchDescription> diff --git a/browser/components/search/test/browser/483086-1.xml b/browser/components/search/test/browser/483086-1.xml new file mode 100644 index 0000000000..765cd13d4f --- /dev/null +++ b/browser/components/search/test/browser/483086-1.xml @@ -0,0 +1,10 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>483086a</ShortName> + <Description>Bug 483086 Test 1</Description> + <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search"> + <Param name="test" value="{searchTerms}"/> + </Url> + <moz:SearchForm>foo://example.com</moz:SearchForm> +</OpenSearchDescription> diff --git a/browser/components/search/test/browser/483086-2.xml b/browser/components/search/test/browser/483086-2.xml new file mode 100644 index 0000000000..ce952ac2e7 --- /dev/null +++ b/browser/components/search/test/browser/483086-2.xml @@ -0,0 +1,10 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>483086b</ShortName> + <Description>Bug 483086 Test 2</Description> + <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search"> + <Param name="test" value="{searchTerms}"/> + </Url> + <moz:SearchForm>http://example.com</moz:SearchForm> +</OpenSearchDescription> diff --git a/browser/components/search/test/browser/browser.ini b/browser/components/search/test/browser/browser.ini new file mode 100644 index 0000000000..09fcd7e50f --- /dev/null +++ b/browser/components/search/test/browser/browser.ini @@ -0,0 +1,176 @@ +[DEFAULT] +support-files = + mozsearch.sjs + test_search.html + 426329.xml + 483086-1.xml + 483086-2.xml + discovery.html + head.js + opensearch.html + test.html + testEngine.xml + testEngine_diacritics.xml + testEngine_dupe.xml + testEngine_mozsearch.xml + tooManyEnginesOffered.html + +[browser_426329.js] +[browser_483086.js] +[browser_addKeywordSearch.js] +[browser_contentContextMenu.js] +support-files = browser_contentContextMenu.xhtml +[browser_contentSearchUI.js] +support-files = + contentSearchUI.html + contentSearchUI.js + searchSuggestionEngine.sjs +[browser_contentSearchUI_default.js] +[browser_contextSearchTabPosition.js] +[browser_contextmenu.js] +[browser_contextmenu_whereToOpenLink.js] +[browser_defaultPrivate_nimbus.js] +support-files = + search-engines/basic/manifest.json + search-engines/private/manifest.json +[browser_google_behavior.js] +[browser_hiddenOneOffs_cleanup.js] +[browser_hiddenOneOffs_diacritics.js] +[browser_ime_composition.js] +[browser_oneOffContextMenu.js] +[browser_oneOffContextMenu_setDefault.js] +[browser_private_search_perwindowpb.js] +[browser_rich_suggestions.js] +support-files = + trendingSuggestionEngine.sjs +[browser_searchEngine_behaviors.js] +[browser_search_annotation.js] +[browser_search_discovery.js] +[browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js] +tags = search-telemetry +support-files = + searchTelemetryAd.html +[browser_search_nimbus_reload.js] +[browser_search_telemetry_abandonment.js] +tags = search-telemetry +support-files = + searchTelemetry.html + searchTelemetryAd.html +[browser_search_telemetry_aboutHome.js] +tags = search-telemetry +[browser_search_telemetry_adImpression_component.js] +tags = search-telemetry +support-files = + searchTelemetryAd_components_carousel.html + searchTelemetryAd_components_carousel_below_the_fold.html + searchTelemetryAd_components_carousel_doubled.html + searchTelemetryAd_components_carousel_first_element_non_visible.html + searchTelemetryAd_components_carousel_hidden.html + searchTelemetryAd_components_carousel_outer_container.html + searchTelemetryAd_components_text.html + searchTelemetryAd_components_visibility.html + serp.css +[browser_search_telemetry_categorization_timing.js] +[browser_search_telemetry_content.js] +tags = search-telemetry +[browser_search_telemetry_engagement_cached.js] +tags = search-telemetry +support-files = + cacheable.html + cacheable.html^headers^ + searchTelemetryAd_components_text.html + serp.css +[browser_search_telemetry_engagement_cached_serp.js] +tags = search-telemetry +support-files = + searchTelemetryAd_searchbox.html + searchTelemetryAd_searchbox.html^headers^ +[browser_search_telemetry_engagement_content.js] +tags = search-telemetry +support-files = + searchTelemetryAd_searchbox_with_content.html + searchTelemetryAd_searchbox_with_content.html^headers^ + searchTelemetryAd_searchbox_with_content_redirect.html + searchTelemetryAd_searchbox_with_content_redirect.html^headers^ + serp.css +[browser_search_telemetry_engagement_multiple_tabs.js] +tags = search-telemetry +support-files = + searchTelemetryAd_searchbox_with_content.html + searchTelemetryAd_searchbox_with_content.html^headers^ +[browser_search_telemetry_engagement_non_ad.js] +tags = search-telemetry +support-files = + searchTelemetryAd_searchbox_with_content.html + searchTelemetryAd_searchbox_with_content.html^headers^ + serp.css +[browser_search_telemetry_engagement_redirect.js] +tags = search-telemetry +support-files = + redirect_ad.sjs + redirect_final.sjs + redirect_once.sjs + redirect_thrice.sjs + redirect_twice.sjs + searchTelemetryAd_components_text.html + searchTelemetryAd_nonAdsLink_redirect.html + searchTelemetryAd_nonAdsLink_redirect.html^headers^ + searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html + searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ + serp.css +[browser_search_telemetry_engagement_target.js] +tags = search-telemetry +support-files = + searchTelemetryAd_components_text.html + searchTelemetryAd_searchbox.html + searchTelemetryAd_searchbox.html^headers^ + serp.css +[browser_search_telemetry_searchbar.js] +https_first_disabled = true +tags = search-telemetry +support-files = + slow_loading_page_with_ads_on_load_event.html + slow_loading_page_with_ads.html + slow_loading_page_with_ads.sjs + telemetrySearchSuggestions.sjs + telemetrySearchSuggestions.xml +[browser_search_telemetry_shopping.js] +tags = search-telemetry +support-files = + searchTelemetryAd_shopping.html +[browser_search_telemetry_sources.js] +tags = search-telemetry +support-files = + searchTelemetry.html + searchTelemetryAd.html +[browser_search_telemetry_sources_ads.js] +tags = search-telemetry +support-files = + searchTelemetry.html + searchTelemetryAd.html + searchTelemetryAd_dataAttributes.html + searchTelemetryAd_dataAttributes_href.html + searchTelemetryAd_dataAttributes_none.html +[browser_search_telemetry_sources_in_content.js] +tags = search-telemetry +support-files = + searchTelemetryAd_searchbox_with_content.html +[browser_search_telemetry_sources_navigation.js] +tags = search-telemetry +support-files = + searchTelemetry.html + searchTelemetryAd.html +[browser_searchbar_addEngine.js] +[browser_searchbar_context.js] +[browser_searchbar_default.js] +[browser_searchbar_enter.js] +[browser_searchbar_keyboard_navigation.js] +skip-if = (os == 'win' && debug) || (os == 'linux' && asan || debug || tsan) # Bug 1792718 +[browser_searchbar_openpopup.js] +[browser_searchbar_results.js] +[browser_searchbar_smallpanel_keyboard_navigation.js] +[browser_searchbar_widths.js] +[browser_tooManyEnginesOffered.js] +[browser_trending_suggestions.js] +support-files = + trendingSuggestionEngine.sjs diff --git a/browser/components/search/test/browser/browser_426329.js b/browser/components/search/test/browser/browser_426329.js new file mode 100644 index 0000000000..85b7fca2ee --- /dev/null +++ b/browser/components/search/test/browser/browser_426329.js @@ -0,0 +1,335 @@ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +ChromeUtils.defineESModuleGetters(this, { + FormHistoryTestUtils: + "resource://testing-common/FormHistoryTestUtils.sys.mjs", +}); + +function expectedURL(aSearchTerms) { + const ENGINE_HTML_BASE = + "http://mochi.test:8888/browser/browser/components/search/test/browser/test.html"; + let searchArg = Services.textToSubURI.ConvertAndEscape("utf-8", aSearchTerms); + return ENGINE_HTML_BASE + "?test=" + searchArg; +} + +function simulateClick(aEvent, aTarget) { + let event = document.createEvent("MouseEvent"); + let ctrlKeyArg = aEvent.ctrlKey || false; + let altKeyArg = aEvent.altKey || false; + let shiftKeyArg = aEvent.shiftKey || false; + let metaKeyArg = aEvent.metaKey || false; + let buttonArg = aEvent.button || 0; + event.initMouseEvent( + "click", + true, + true, + window, + 0, + 0, + 0, + 0, + 0, + ctrlKeyArg, + altKeyArg, + shiftKeyArg, + metaKeyArg, + buttonArg, + null + ); + aTarget.dispatchEvent(event); +} + +// modified from toolkit/components/satchel/test/test_form_autocomplete.html +function checkMenuEntries(expectedValues) { + let actualValues = getMenuEntries(); + is( + actualValues.length, + expectedValues.length, + "Checking length of expected menu" + ); + for (let i = 0; i < expectedValues.length; i++) { + is(actualValues[i], expectedValues[i], "Checking menu entry #" + i); + } +} + +function getMenuEntries() { + // Could perhaps pull values directly from the controller, but it seems + // more reliable to test the values that are actually in the richlistbox? + return Array.from(searchBar.textbox.popup.richlistbox.itemChildren, item => + item.getAttribute("ac-value") + ); +} + +var searchBar; +var searchButton; +var searchEntries = ["test"]; +function promiseSetEngine() { + return new Promise(resolve => { + let ss = Services.search; + + function observer(aSub, aTopic, aData) { + switch (aData) { + case "engine-added": + let engine = ss.getEngineByName("Bug 426329"); + ok(engine, "Engine was added."); + ss.defaultEngine = engine; + break; + case "engine-default": + ok(ss.defaultEngine.name == "Bug 426329", "defaultEngine set"); + searchBar = BrowserSearch.searchBar; + searchButton = searchBar.querySelector(".search-go-button"); + ok(searchButton, "got search-go-button"); + + Services.obs.removeObserver( + observer, + "browser-search-engine-modified" + ); + resolve(); + break; + } + } + + Services.obs.addObserver(observer, "browser-search-engine-modified"); + ss.addOpenSearchEngine( + "http://mochi.test:8888/browser/browser/components/search/test/browser/426329.xml", + "data:image/x-icon,%00" + ); + }); +} + +function promiseRemoveEngine() { + return new Promise(resolve => { + let ss = Services.search; + + function observer(aSub, aTopic, aData) { + if (aData == "engine-removed") { + Services.obs.removeObserver(observer, "browser-search-engine-modified"); + resolve(); + } + } + + Services.obs.addObserver(observer, "browser-search-engine-modified"); + let engine = ss.getEngineByName("Bug 426329"); + ss.removeEngine(engine); + }); +} + +var preSelectedBrowser; +var preTabNo; +async function prepareTest() { + await Services.search.init(); + + preSelectedBrowser = gBrowser.selectedBrowser; + preTabNo = gBrowser.tabs.length; + searchBar = BrowserSearch.searchBar; + + await SimpleTest.promiseFocus(); + + if (document.activeElement == searchBar) { + return; + } + + let focusPromise = BrowserTestUtils.waitForEvent(searchBar.textbox, "focus"); + gURLBar.focus(); + searchBar.focus(); + await focusPromise; +} + +add_task(async function testSetup() { + await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function testSetupEngine() { + await promiseSetEngine(); + searchBar.value = "test"; +}); + +add_task(async function testReturn() { + await prepareTest(); + EventUtils.synthesizeKey("KEY_Enter"); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + is(gBrowser.tabs.length, preTabNo, "Return key did not open new tab"); + is( + gBrowser.currentURI.spec, + expectedURL(searchBar.value), + "testReturn opened correct search page" + ); +}); + +add_task(async function testAltReturn() { + await prepareTest(); + await BrowserTestUtils.openNewForegroundTab(gBrowser, () => { + EventUtils.synthesizeKey("KEY_Enter", { altKey: true }); + }); + + is(gBrowser.tabs.length, preTabNo + 1, "Alt+Return key added new tab"); + is( + gBrowser.currentURI.spec, + expectedURL(searchBar.value), + "testAltReturn opened correct search page" + ); +}); + +add_task(async function testAltGrReturn() { + await prepareTest(); + await BrowserTestUtils.openNewForegroundTab(gBrowser, () => { + EventUtils.synthesizeKey("KEY_Enter", { altGraphKey: true }); + }); + + is(gBrowser.tabs.length, preTabNo + 1, "AltGr+Return key added new tab"); + is( + gBrowser.currentURI.spec, + expectedURL(searchBar.value), + "testAltGrReturn opened correct search page" + ); +}); + +// Shift key has no effect for now, so skip it +add_task(async function testShiftAltReturn() { + /* + yield* prepareTest(); + + let url = expectedURL(searchBar.value); + + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, url); + EventUtils.synthesizeKey("VK_RETURN", { shiftKey: true, altKey: true }); + yield newTabPromise; + + is(gBrowser.tabs.length, preTabNo + 1, "Shift+Alt+Return key added new tab"); + is(gBrowser.currentURI.spec, url, "testShiftAltReturn opened correct search page"); + */ +}); + +add_task(async function testLeftClick() { + await prepareTest(); + simulateClick({ button: 0 }, searchButton); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + is(gBrowser.tabs.length, preTabNo, "LeftClick did not open new tab"); + is( + gBrowser.currentURI.spec, + expectedURL(searchBar.value), + "testLeftClick opened correct search page" + ); +}); + +add_task(async function testMiddleClick() { + await prepareTest(); + await BrowserTestUtils.openNewForegroundTab(gBrowser, () => { + simulateClick({ button: 1 }, searchButton); + }); + is(gBrowser.tabs.length, preTabNo + 1, "MiddleClick added new tab"); + is( + gBrowser.currentURI.spec, + expectedURL(searchBar.value), + "testMiddleClick opened correct search page" + ); +}); + +add_task(async function testShiftMiddleClick() { + await prepareTest(); + + let url = expectedURL(searchBar.value); + + let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, url); + simulateClick({ button: 1, shiftKey: true }, searchButton); + let newTab = await newTabPromise; + + is(gBrowser.tabs.length, preTabNo + 1, "Shift+MiddleClick added new tab"); + is( + newTab.linkedBrowser.currentURI.spec, + url, + "testShiftMiddleClick opened correct search page" + ); +}); + +add_task(async function testRightClick() { + preTabNo = gBrowser.tabs.length; + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank", { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}), + }); + await new Promise(resolve => { + setTimeout(function () { + is(gBrowser.tabs.length, preTabNo, "RightClick did not open new tab"); + is(gBrowser.currentURI.spec, "about:blank", "RightClick did nothing"); + resolve(); + }, 5000); + simulateClick({ button: 2 }, searchButton); + }); + // The click in the searchbox focuses it, which opens the suggestion + // panel. Clean up after ourselves. + searchBar.textbox.popup.hidePopup(); +}); + +add_task(async function testSearchHistory() { + let textbox = searchBar._textbox; + for (let i = 0; i < searchEntries.length; i++) { + let count = await FormHistoryTestUtils.count( + textbox.getAttribute("autocompletesearchparam"), + { value: searchEntries[i], source: "Bug 426329" } + ); + ok(count > 0, "form history entry '" + searchEntries[i] + "' should exist"); + } +}); + +add_task(async function testAutocomplete() { + let popup = searchBar.textbox.popup; + let popupShownPromise = BrowserTestUtils.waitForEvent(popup, "popupshown"); + searchBar.textbox.showHistoryPopup(); + await popupShownPromise; + checkMenuEntries(searchEntries); + searchBar.textbox.closePopup(); +}); + +add_task(async function testClearHistory() { + // Open the textbox context menu to trigger controller attachment. + let textbox = searchBar.textbox; + let popupShownPromise = BrowserTestUtils.waitForEvent( + window, + "popupshown", + false, + event => event.target.classList.contains("textbox-contextmenu") + ); + EventUtils.synthesizeMouseAtCenter(textbox, { + type: "contextmenu", + button: 2, + }); + await popupShownPromise; + // Close the context menu. + let contextMenu = document.querySelector(".textbox-contextmenu"); + contextMenu.hidePopup(); + + let menuitem = searchBar._menupopup.querySelector(".searchbar-clear-history"); + ok(!menuitem.disabled, "Clear history menuitem enabled"); + + let historyCleared = promiseObserver("satchel-storage-changed"); + searchBar._menupopup.activateItem(menuitem); + await historyCleared; + let count = await FormHistoryTestUtils.count( + textbox.getAttribute("autocompletesearchparam") + ); + ok(count == 0, "History cleared"); +}); + +add_task(async function asyncCleanup() { + searchBar.value = ""; + while (gBrowser.tabs.length != 1) { + gBrowser.removeTab(gBrowser.tabs[0], { animate: false }); + } + BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank", { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}), + }); + await promiseRemoveEngine(); +}); + +function promiseObserver(topic) { + return new Promise(resolve => { + let obs = (aSubject, aTopic, aData) => { + Services.obs.removeObserver(obs, aTopic); + resolve(aSubject); + }; + Services.obs.addObserver(obs, topic); + }); +} diff --git a/browser/components/search/test/browser/browser_483086.js b/browser/components/search/test/browser/browser_483086.js new file mode 100644 index 0000000000..9a487c403b --- /dev/null +++ b/browser/components/search/test/browser/browser_483086.js @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +var gSS = Services.search; + +function test() { + waitForExplicitFinish(); + + function observer(aSubject, aTopic, aData) { + switch (aData) { + case "engine-added": + let engine = gSS.getEngineByName("483086a"); + ok(engine, "Test engine 1 installed"); + isnot( + engine.searchForm, + "foo://example.com", + "Invalid SearchForm URL dropped" + ); + gSS.removeEngine(engine); + break; + case "engine-removed": + Services.obs.removeObserver(observer, "browser-search-engine-modified"); + test2(); + break; + } + } + + Services.obs.addObserver(observer, "browser-search-engine-modified"); + gSS.addOpenSearchEngine( + "http://mochi.test:8888/browser/browser/components/search/test/browser/483086-1.xml", + "data:image/x-icon;%00" + ); +} + +function test2() { + function observer(aSubject, aTopic, aData) { + switch (aData) { + case "engine-added": + let engine = gSS.getEngineByName("483086b"); + ok(engine, "Test engine 2 installed"); + is(engine.searchForm, "http://example.com", "SearchForm is correct"); + gSS.removeEngine(engine); + break; + case "engine-removed": + Services.obs.removeObserver(observer, "browser-search-engine-modified"); + finish(); + break; + } + } + + Services.obs.addObserver(observer, "browser-search-engine-modified"); + gSS.addOpenSearchEngine( + "http://mochi.test:8888/browser/browser/components/search/test/browser/483086-2.xml", + "data:image/x-icon;%00" + ); +} diff --git a/browser/components/search/test/browser/browser_addKeywordSearch.js b/browser/components/search/test/browser/browser_addKeywordSearch.js new file mode 100644 index 0000000000..6de653fd89 --- /dev/null +++ b/browser/components/search/test/browser/browser_addKeywordSearch.js @@ -0,0 +1,89 @@ +var testData = [ + { desc: "No path", action: "http://example.com/", param: "q" }, + { + desc: "With path", + action: "http://example.com/new-path-here/", + param: "q", + }, + { desc: "No action", action: "", param: "q" }, + { + desc: "With Query String", + action: "http://example.com/search?oe=utf-8", + param: "q", + }, +]; + +add_task(async function () { + const TEST_URL = + "http://example.org/browser/browser/components/search/test/browser/test.html"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + let count = 0; + for (let method of ["GET", "POST"]) { + for (let { desc, action, param } of testData) { + info(`Running ${method} keyword test '${desc}'`); + let id = `keyword-form-${count++}`; + let contextMenu = document.getElementById("contentAreaContextMenu"); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + await SpecialPowers.spawn( + tab.linkedBrowser, + [{ action, param, method, id }], + async function (args) { + let doc = content.document; + let form = doc.createElement("form"); + form.id = args.id; + form.method = args.method; + form.action = args.action; + let element = doc.createElement("input"); + element.setAttribute("type", "text"); + element.setAttribute("name", args.param); + form.appendChild(element); + doc.body.appendChild(form); + } + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${id} > input`, + { type: "contextmenu", button: 2 }, + tab.linkedBrowser + ); + await contextMenuPromise; + let url = action || tab.linkedBrowser.currentURI.spec; + let actor = gContextMenu.actor; + + let data = await actor.getSearchFieldBookmarkData( + gContextMenu.targetIdentifier + ); + if (method == "GET") { + ok( + data.spec.endsWith(`${param}=%s`), + `Check expected url for field named ${param} and action ${action}` + ); + } else { + is( + data.spec, + url, + `Check expected url for field named ${param} and action ${action}` + ); + is( + data.postData, + `${param}%3D%25s`, + `Check expected POST data for field named ${param} and action ${action}` + ); + } + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; + } + } + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_contentContextMenu.js b/browser/components/search/test/browser/browser_contentContextMenu.js new file mode 100644 index 0000000000..684428821e --- /dev/null +++ b/browser/components/search/test/browser/browser_contentContextMenu.js @@ -0,0 +1,230 @@ +/* Make sure context menu includes option to search hyperlink text on search + * engine. + */ + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault", true], + ["browser.search.separatePrivateDefault.ui.enabled", true], + ], + }); + + const url = + "http://mochi.test:8888/browser/browser/components/search/test/browser/browser_contentContextMenu.xhtml"; + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + const ellipsis = "\u2026"; + + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + + const originalPrivateDefault = await Services.search.getDefaultPrivate(); + let otherPrivateDefault; + for (let engine of await Services.search.getVisibleEngines()) { + if (engine.name != originalPrivateDefault.name) { + otherPrivateDefault = engine; + break; + } + } + + // Tests if the "Search <engine> for '<some terms>'" context menu item is + // shown for the given query string of an element. Tests to make sure label + // includes the proper search terms. + // + // Each test: + // + // id: The id of the element to test. + // isSelected: Flag to enable selecting (text highlight) the contents of the + // element. + // shouldBeShown: The display state of the menu item. + // expectedLabelContents: The menu item label should contain a portion of + // this string. Will only be tested if shouldBeShown + // is true. + // shouldPrivateBeShown: The display state of the Private Window menu item. + // expectedPrivateLabelContents: The menu item label for the Private Window + // should contain a portion of this string. + // Will only be tested if shouldPrivateBeShown + // is true. + let tests = [ + { + id: "link", + isSelected: true, + shouldBeShown: true, + expectedLabelContents: "I'm a link!", + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search in", + }, + { + id: "link", + isSelected: false, + shouldBeShown: true, + expectedLabelContents: "I'm a link!", + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search in", + }, + { + id: "longLink", + isSelected: true, + shouldBeShown: true, + expectedLabelContents: "I'm a really lo" + ellipsis, + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search in", + }, + { + id: "longLink", + isSelected: false, + shouldBeShown: true, + expectedLabelContents: "I'm a really lo" + ellipsis, + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search in", + }, + { + id: "plainText", + isSelected: true, + shouldBeShown: true, + expectedLabelContents: "Right clicking " + ellipsis, + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search in", + }, + { + id: "plainText", + isSelected: false, + shouldBeShown: false, + shouldPrivateBeShown: false, + }, + { + id: "mixedContent", + isSelected: true, + shouldBeShown: true, + expectedLabelContents: "I'm some text, " + ellipsis, + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search in", + }, + { + id: "mixedContent", + isSelected: false, + shouldBeShown: false, + shouldPrivateBeShown: false, + }, + { + id: "partialLink", + isSelected: true, + shouldBeShown: true, + expectedLabelContents: "link selection", + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search in", + }, + { + id: "partialLink", + isSelected: false, + shouldBeShown: true, + expectedLabelContents: "A partial link " + ellipsis, + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search with " + otherPrivateDefault.name, + changePrivateDefaultEngine: true, + }, + { + id: "surrogatePair", + isSelected: true, + shouldBeShown: true, + expectedLabelContents: "This character\uD83D\uDD25" + ellipsis, + shouldPrivateBeShown: true, + expectedPrivateLabelContents: "Search with " + otherPrivateDefault.name, + changePrivateDefaultEngine: true, + }, + ]; + + for (let test of tests) { + if (test.changePrivateDefaultEngine) { + await Services.search.setDefaultPrivate( + otherPrivateDefault, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + } + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [{ selectElement: test.isSelected ? test.id : null }], + async function (arg) { + let selection = content.getSelection(); + selection.removeAllRanges(); + + if (arg.selectElement) { + selection.selectAllChildren( + content.document.getElementById(arg.selectElement) + ); + } + } + ); + + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#" + test.id, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupShownPromise; + + let menuItem = document.getElementById("context-searchselect"); + is( + menuItem.hidden, + !test.shouldBeShown, + "search context menu item is shown for '#" + + test.id + + "' and selected is '" + + test.isSelected + + "'" + ); + + if (test.shouldBeShown) { + ok( + menuItem.label.includes(test.expectedLabelContents), + "Menu item text '" + + menuItem.label + + "' contains the correct search terms '" + + test.expectedLabelContents + + "'" + ); + } + + menuItem = document.getElementById("context-searchselect-private"); + is( + menuItem.hidden, + !test.shouldPrivateBeShown, + "private search context menu item is shown for '#" + test.id + "' " + ); + + if (test.shouldPrivateBeShown) { + ok( + menuItem.label.includes(test.expectedPrivateLabelContents), + "Menu item text '" + + menuItem.label + + "' contains the correct search terms '" + + test.expectedPrivateLabelContents + + "'" + ); + } + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popuphidden" + ); + contentAreaContextMenu.hidePopup(); + await popupHiddenPromise; + + if (test.changePrivateDefaultEngine) { + await Services.search.setDefaultPrivate( + originalPrivateDefault, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + } + } + + // Cleanup. + gBrowser.removeCurrentTab(); +}); diff --git a/browser/components/search/test/browser/browser_contentContextMenu.xhtml b/browser/components/search/test/browser/browser_contentContextMenu.xhtml new file mode 100644 index 0000000000..16e32eb8ac --- /dev/null +++ b/browser/components/search/test/browser/browser_contentContextMenu.xhtml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="UTF-8"?> +<html xmlns="http://www.w3.org/1999/xhtml"> + <body> + <a href="http://mozilla.org" id="link">I'm a link!</a> + <br/> + <a href="http://mozilla.org" id="longLink">I'm a really long link and I should be truncated.</a> + <br/> + <span id="plainText"> + Right clicking me when I'm selected should show the menu item. + </span> + <br/> + <span id="mixedContent"> + I'm some text, and <a href="http://mozilla.org">I'm a link!</a> + </span> + <br/> + <a href="http://mozilla.org">A partial <span id="partialLink">link selection</span></a> + <br/> + <span id="surrogatePair"> + This character🔥 shouldn't be truncated. + </span> + </body> +</html> diff --git a/browser/components/search/test/browser/browser_contentSearchUI.js b/browser/components/search/test/browser/browser_contentSearchUI.js new file mode 100644 index 0000000000..9196b1355c --- /dev/null +++ b/browser/components/search/test/browser/browser_contentSearchUI.js @@ -0,0 +1,1158 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_PAGE_BASENAME = "contentSearchUI.html"; + +const TEST_ENGINE1 = { + name: "searchSuggestionEngine1", + id: "other-searchSuggestionEngine1", + loadPath: "[addon]searchsuggestionengine1@tests.mozilla.org", +}; +const TEST_ENGINE2 = { + name: "searchSuggestionEngine2", + id: "other-searchSuggestionEngine2", + loadPath: "[addon]searchsuggestionengine2@tests.mozilla.org", +}; + +const TEST_MSG = "ContentSearchUIControllerTest"; + +ChromeUtils.defineESModuleGetters(this, { + ContentSearch: "resource:///actors/ContentSearchParent.sys.mjs", + FormHistoryTestUtils: + "resource://testing-common/FormHistoryTestUtils.sys.mjs", + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.sys.mjs", +}); + +const pageURL = getRootDirectory(gTestPath) + TEST_PAGE_BASENAME; +BrowserTestUtils.registerAboutPage( + registerCleanupFunction, + "test-about-content-search-ui", + pageURL, + Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT | + Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD | + Ci.nsIAboutModule.ALLOW_SCRIPT | + Ci.nsIAboutModule.URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS +); + +requestLongerTimeout(2); + +function waitForSuggestions() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => + ContentTaskUtils.waitForCondition( + () => + Cu.waiveXrays(content).gController.input.getAttribute( + "aria-expanded" + ) == "true", + "Waiting for suggestions", + 200 // Increased interval to support long textruns. + ) + ); +} + +async function waitForSearch() { + await BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "ContentSearchClient", + true, + event => { + if (event.detail.type == "Search") { + event.target._eventDetail = event.detail.data; + return true; + } + return false; + }, + true + ); + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let eventDetail = content._eventDetail; + delete content._eventDetail; + return eventDetail; + }); +} + +async function waitForSearchSettings() { + await BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "ContentSearchClient", + true, + event => { + if (event.detail.type == "ManageEngines") { + event.target._eventDetail = event.detail.data; + return true; + } + return false; + }, + true + ); + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let eventDetail = content._eventDetail; + delete content._eventDetail; + return eventDetail; + }); +} + +function getCurrentState() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let controller = Cu.waiveXrays(content).gController; + let state = { + selectedIndex: controller.selectedIndex, + selectedButtonIndex: controller.selectedButtonIndex, + numSuggestions: controller._table.hidden ? 0 : controller.numSuggestions, + suggestionAtIndex: [], + isFormHistorySuggestionAtIndex: [], + + tableHidden: controller._table.hidden, + + inputValue: controller.input.value, + ariaExpanded: controller.input.getAttribute("aria-expanded"), + }; + + if (state.numSuggestions) { + for (let i = 0; i < controller.numSuggestions; i++) { + state.suggestionAtIndex.push(controller.suggestionAtIndex(i)); + state.isFormHistorySuggestionAtIndex.push( + controller.isFormHistorySuggestionAtIndex(i) + ); + } + } + + return state; + }); +} + +async function msg(type, data = null) { + switch (type) { + case "reset": + // Reset both the input and suggestions by select all + delete. If there was + // no text entered, this won't have any effect, so also escape to ensure the + // suggestions table is closed. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + Cu.waiveXrays(content).gController.input.focus(); + EventUtils.synthesizeKey("a", { accelKey: true }, content); + EventUtils.synthesizeKey("KEY_Delete", {}, content); + EventUtils.synthesizeKey("KEY_Escape", {}, content); + }); + break; + + case "key": { + let keyName = typeof data == "string" ? data : data.key; + await BrowserTestUtils.synthesizeKey( + keyName, + data.modifiers || {}, + gBrowser.selectedBrowser + ); + if (data?.waitForSuggestions) { + await waitForSuggestions(); + } + break; + } + case "text": { + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [data.value], + text => { + Cu.waiveXrays(content).gController.input.value = text.substring( + 0, + text.length - 1 + ); + EventUtils.synthesizeKey( + text.substring(text.length - 1), + {}, + content + ); + } + ); + if (data?.waitForSuggestions) { + await waitForSuggestions(); + } + break; + } + case "startComposition": + await BrowserTestUtils.synthesizeComposition( + "compositionstart", + gBrowser.selectedBrowser + ); + break; + case "changeComposition": { + await BrowserTestUtils.synthesizeCompositionChange( + { + composition: { + string: data.data, + clauses: [ + { + length: data.length, + attr: Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE, + }, + ], + }, + caret: { start: data.length, length: 0 }, + }, + gBrowser.selectedBrowser + ); + if (data?.waitForSuggestions) { + await waitForSuggestions(); + } + break; + } + case "commitComposition": + await BrowserTestUtils.synthesizeComposition( + "compositioncommitasis", + gBrowser.selectedBrowser + ); + break; + case "mousemove": + case "click": { + let event; + let index; + if (type == "mousemove") { + event = { + type: "mousemove", + clickcount: 0, + }; + index = data; + } else { + event = data.modifiers || null; + index = data.eltIdx; + } + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [type, event, index], + (eventType, eventArgs, itemIndex) => { + let controller = Cu.waiveXrays(content).gController; + return new Promise(resolve => { + let row; + if (itemIndex == -1) { + row = controller._table.firstChild; + } else { + let allElts = [ + ...controller._suggestionsList.children, + ...controller._oneOffButtons, + content.document.getElementById("contentSearchSettingsButton"), + ]; + row = allElts[itemIndex]; + } + row.addEventListener(eventType, () => resolve(), { once: true }); + EventUtils.synthesizeMouseAtCenter(row, eventArgs, content); + }); + } + ); + break; + } + } + + return getCurrentState(); +} + +/** + * Focusses the in-content search bar. + * + * @returns {Promise} + * A promise that is resolved once the focus is complete. + */ +function focusContentSearchBar() { + return SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + Cu.waiveXrays(content).input.focus(); + }); +} + +let extension1; +let extension2; + +add_setup(async function () { + let originalOnMessageSearch = ContentSearch._onMessageSearch; + let originalOnMessageManageEngines = ContentSearch._onMessageManageEngines; + + ContentSearch._onMessageSearch = () => {}; + ContentSearch._onMessageManageEngines = () => {}; + + let currentEngines = await Services.search.getVisibleEngines(); + + extension1 = await SearchTestUtils.installSearchExtension( + { + name: TEST_ENGINE1.name, + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + extension2 = await SearchTestUtils.installSearchExtension({ + name: TEST_ENGINE2.name, + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }); + + for (let engine of currentEngines) { + await Services.search.removeEngine(engine); + } + + registerCleanupFunction(async () => { + ContentSearch._onMessageSearch = originalOnMessageSearch; + ContentSearch._onMessageManageEngines = originalOnMessageManageEngines; + }); + + await promiseTab(); +}); + +add_task(async function emptyInput() { + await focusContentSearchBar(); + + let state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("key", "VK_BACK_SPACE"); + checkState(state, "", [], -1); + + await msg("reset"); +}); + +add_task(async function blur() { + await focusContentSearchBar(); + + let state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + Cu.waiveXrays(content).gController.input.blur(); + }); + state = await getCurrentState(); + checkState(state, "x", [], -1); + + await msg("reset"); +}); + +add_task(async function upDownKeys() { + await focusContentSearchBar(); + + let state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + // Cycle down the suggestions starting from no selection. + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoo", ["xfoo", "xbar"], 0); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xbar", ["xfoo", "xbar"], 1); + + state = await msg("key", "VK_DOWN"); + checkState(state, "x", ["xfoo", "xbar"], 2); + + state = await msg("key", "VK_DOWN"); + checkState(state, "x", ["xfoo", "xbar"], 3); + + state = await msg("key", "VK_DOWN"); + checkState(state, "x", ["xfoo", "xbar"], -1); + + // Cycle up starting from no selection. + state = await msg("key", "VK_UP"); + checkState(state, "x", ["xfoo", "xbar"], 3); + + state = await msg("key", "VK_UP"); + checkState(state, "x", ["xfoo", "xbar"], 2); + + state = await msg("key", "VK_UP"); + checkState(state, "xbar", ["xfoo", "xbar"], 1); + + state = await msg("key", "VK_UP"); + checkState(state, "xfoo", ["xfoo", "xbar"], 0); + + state = await msg("key", "VK_UP"); + checkState(state, "x", ["xfoo", "xbar"], -1); + + await msg("reset"); +}); + +add_task(async function rightLeftKeys() { + await focusContentSearchBar(); + + let state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("key", "VK_LEFT"); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("key", "VK_LEFT"); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("key", "VK_RIGHT"); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("key", "VK_RIGHT"); + checkState(state, "x", [], -1); + + state = await msg("key", { key: "VK_DOWN", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoo", ["xfoo", "xbar"], 0); + + // This should make the xfoo suggestion sticky. To make sure it sticks, + // trigger suggestions again and cycle through them by pressing Down until + // nothing is selected again. + state = await msg("key", "VK_RIGHT"); + checkState(state, "xfoo", [], -1); + + state = await msg("key", { key: "VK_DOWN", waitForSuggestions: true }); + checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoofoo", ["xfoofoo", "xfoobar"], 0); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoobar", ["xfoofoo", "xfoobar"], 1); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoo", ["xfoofoo", "xfoobar"], 2); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoo", ["xfoofoo", "xfoobar"], 3); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoo", ["xfoofoo", "xfoobar"], -1); + + await msg("reset"); +}); + +add_task(async function tabKey() { + await focusContentSearchBar(); + await msg("key", { key: "x", waitForSuggestions: true }); + + let state = await msg("key", "VK_TAB"); + checkState(state, "x", ["xfoo", "xbar"], 2); + + state = await msg("key", "VK_TAB"); + checkState(state, "x", ["xfoo", "xbar"], 3); + + state = await msg("key", { key: "VK_TAB", modifiers: { shiftKey: true } }); + checkState(state, "x", ["xfoo", "xbar"], 2); + + state = await msg("key", { key: "VK_TAB", modifiers: { shiftKey: true } }); + checkState(state, "x", [], -1); + + await focusContentSearchBar(); + + await msg("key", { key: "VK_DOWN", waitForSuggestions: true }); + + for (let i = 0; i < 3; ++i) { + state = await msg("key", "VK_TAB"); + } + checkState(state, "x", [], -1); + + await focusContentSearchBar(); + + await msg("key", { key: "VK_DOWN", waitForSuggestions: true }); + state = await msg("key", "VK_DOWN"); + checkState(state, "xfoo", ["xfoo", "xbar"], 0); + + state = await msg("key", "VK_TAB"); + checkState(state, "xfoo", ["xfoo", "xbar"], 0, 0); + + state = await msg("key", "VK_TAB"); + checkState(state, "xfoo", ["xfoo", "xbar"], 0, 1); + + state = await msg("key", "VK_DOWN"); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 1); + + state = await msg("key", "VK_DOWN"); + checkState(state, "x", ["xfoo", "xbar"], 2); + + state = await msg("key", "VK_UP"); + checkState(state, "xbar", ["xfoo", "xbar"], 1); + + state = await msg("key", "VK_TAB"); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 0); + + state = await msg("key", "VK_TAB"); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 1); + + state = await msg("key", "VK_TAB"); + checkState(state, "xbar", [], -1); + + await msg("reset"); +}); + +add_task(async function cycleSuggestions() { + await focusContentSearchBar(); + await msg("key", { key: "x", waitForSuggestions: true }); + + let cycle = async function (aSelectedButtonIndex) { + let modifiers = { + shiftKey: true, + accelKey: true, + }; + + let state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex); + + state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, aSelectedButtonIndex); + + state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex); + + state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, aSelectedButtonIndex); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "xfoo", ["xfoo", "xbar"], 0, aSelectedButtonIndex); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "x", ["xfoo", "xbar"], -1, aSelectedButtonIndex); + }; + + await cycle(); + + // Repeat with a one-off selected. + let state = await msg("key", "VK_TAB"); + checkState(state, "x", ["xfoo", "xbar"], 2); + await cycle(0); + + // Repeat with the settings button selected. + state = await msg("key", "VK_TAB"); + checkState(state, "x", ["xfoo", "xbar"], 3); + await cycle(1); + + await msg("reset"); +}); + +add_task(async function cycleOneOffs() { + await focusContentSearchBar(); + await msg("key", { key: "x", waitForSuggestions: true }); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let btn = + Cu.waiveXrays(content).gController._oneOffButtons[ + Cu.waiveXrays(content).gController._oneOffButtons.length - 1 + ]; + let newBtn = btn.cloneNode(true); + btn.parentNode.appendChild(newBtn); + Cu.waiveXrays(content).gController._oneOffButtons.push(newBtn); + }); + + let state = await msg("key", "VK_DOWN"); + state = await msg("key", "VK_DOWN"); + checkState(state, "xbar", ["xfoo", "xbar"], 1); + + let modifiers = { + altKey: true, + }; + + state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 0); + + state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 1); + + state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 1); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 0); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1); + + // If the settings button is selected, pressing alt+up/down should select the + // last/first one-off respectively (and deselect the settings button). + await msg("key", "VK_TAB"); + await msg("key", "VK_TAB"); + state = await msg("key", "VK_TAB"); // Settings button selected. + checkState(state, "xbar", ["xfoo", "xbar"], 1, 2); + + state = await msg("key", { key: "VK_UP", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 1); + + state = await msg("key", "VK_TAB"); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 2); + + state = await msg("key", { key: "VK_DOWN", modifiers }); + checkState(state, "xbar", ["xfoo", "xbar"], 1, 0); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + Cu.waiveXrays(content).gController._oneOffButtons.pop().remove(); + }); + await msg("reset"); +}); + +add_task(async function mouse() { + await focusContentSearchBar(); + + let state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("mousemove", 0); + checkState(state, "x", ["xfoo", "xbar"], 0); + + state = await msg("mousemove", 1); + checkState(state, "x", ["xfoo", "xbar"], 1); + + state = await msg("mousemove", 2); + checkState(state, "x", ["xfoo", "xbar"], 2, 0); + + state = await msg("mousemove", 3); + checkState(state, "x", ["xfoo", "xbar"], 3, 1); + + state = await msg("mousemove", -1); + checkState(state, "x", ["xfoo", "xbar"], -1); + + await msg("reset"); + await focusContentSearchBar(); + + state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + state = await msg("mousemove", 0); + checkState(state, "x", ["xfoo", "xbar"], 0); + + state = await msg("mousemove", 2); + checkState(state, "x", ["xfoo", "xbar"], 2, 0); + + state = await msg("mousemove", -1); + checkState(state, "x", ["xfoo", "xbar"], -1); + + await msg("reset"); +}); + +add_task(async function formHistory() { + await focusContentSearchBar(); + + // Type an X and add it to form history. + let state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + // Wait for Satchel to say it's been added to form history. + let observePromise = new Promise(resolve => { + Services.obs.addObserver(function onAdd(subj, topic, data) { + if (data == "formhistory-add") { + Services.obs.removeObserver(onAdd, "satchel-storage-changed"); + executeSoon(resolve); + } + }, "satchel-storage-changed"); + }); + + await FormHistoryTestUtils.clear("searchbar-history"); + let entry = await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return Cu.waiveXrays(content).gController.addInputValueToFormHistory(); + }); + await observePromise; + Assert.greater( + await FormHistoryTestUtils.count("searchbar-history", { + source: entry.source, + }), + 0 + ); + + // Reset the input. + state = await msg("reset"); + checkState(state, "", [], -1); + + // Type an X again. The form history entry should appear. + state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState( + state, + "x", + [{ str: "x", type: "formHistory" }, "xfoo", "xbar"], + -1 + ); + + // Select the form history entry and delete it. + state = await msg("key", "VK_DOWN"); + checkState( + state, + "x", + [{ str: "x", type: "formHistory" }, "xfoo", "xbar"], + 0 + ); + + // Wait for Satchel. + observePromise = new Promise(resolve => { + Services.obs.addObserver(function onRemove(subj, topic, data) { + if (data == "formhistory-remove") { + Services.obs.removeObserver(onRemove, "satchel-storage-changed"); + executeSoon(resolve); + } + }, "satchel-storage-changed"); + }); + state = await msg("key", "VK_DELETE"); + checkState(state, "x", ["xfoo", "xbar"], -1); + + await observePromise; + + // Reset the input. + state = await msg("reset"); + checkState(state, "", [], -1); + + // Type an X again. The form history entry should still be gone. + state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + await msg("reset"); +}); + +add_task(async function formHistory_limit() { + info("Check long strings are not added to form history"); + await focusContentSearchBar(); + const gLongString = new Array( + SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + 1 + ) + .fill("x") + .join(""); + // Type and confirm a very long string. + let state = await msg("text", { + value: gLongString, + waitForSuggestions: true, + }); + checkState( + state, + gLongString, + [`${gLongString}foo`, `${gLongString}bar`], + -1 + ); + + await FormHistoryTestUtils.clear("searchbar-history"); + let entry = await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + return Cu.waiveXrays(content).gController.addInputValueToFormHistory(); + }); + // There's nothing we can wait for, since addition should not be happening. + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.equal( + await FormHistoryTestUtils.count("searchbar-history", { + source: entry.source, + }), + 0 + ); + + await msg("reset"); +}); + +add_task(async function cycleEngines() { + await focusContentSearchBar(); + await msg("key", { key: "VK_DOWN", waitForSuggestions: true }); + + Services.telemetry.clearEvents(); + Services.fog.testResetFOG(); + + let p = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await msg("key", { key: "VK_DOWN", modifiers: { accelKey: true } }); + let newEngine = await p; + Assert.equal( + newEngine.name, + TEST_ENGINE2.name, + "Should have correctly cycled the engine" + ); + TelemetryTestUtils.assertEvents( + [ + { + object: "change_default", + value: "user_searchbar", + extra: { + prev_id: TEST_ENGINE1.id, + new_id: TEST_ENGINE2.id, + new_name: TEST_ENGINE2.name, + new_load_path: TEST_ENGINE2.loadPath, + new_sub_url: "", + }, + }, + ], + { category: "search", method: "engine" } + ); + + let snapshot = await Glean.searchEngineDefault.changed.testGetValue(); + delete snapshot[0].timestamp; + Assert.deepEqual( + snapshot[0], + { + category: "search.engine.default", + name: "changed", + extra: { + new_load_path: TEST_ENGINE2.loadPath, + previous_engine_id: TEST_ENGINE1.id, + change_source: "user_searchbar", + new_engine_id: TEST_ENGINE2.id, + new_display_name: TEST_ENGINE2.name, + new_submission_url: "", + }, + }, + "Should have received the correct event details" + ); + + p = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await msg("key", { key: "VK_UP", modifiers: { accelKey: true } }); + newEngine = await p; + Assert.equal( + newEngine.name, + TEST_ENGINE1.name, + "Should have correctly cycled the engine" + ); + + TelemetryTestUtils.assertEvents( + [ + { + object: "change_default", + value: "user_searchbar", + extra: { + prev_id: TEST_ENGINE2.id, + new_id: TEST_ENGINE1.id, + new_name: TEST_ENGINE1.name, + new_load_path: TEST_ENGINE1.loadPath, + new_sub_url: "", + }, + }, + ], + { category: "search", method: "engine" } + ); + + snapshot = await Glean.searchEngineDefault.changed.testGetValue(); + delete snapshot[1].timestamp; + Assert.deepEqual( + snapshot[1], + { + category: "search.engine.default", + name: "changed", + extra: { + new_load_path: TEST_ENGINE1.loadPath, + previous_engine_id: TEST_ENGINE2.id, + change_source: "user_searchbar", + new_engine_id: TEST_ENGINE1.id, + new_display_name: TEST_ENGINE1.name, + new_submission_url: "", + }, + }, + "Should have received the correct event details" + ); + + await msg("reset"); +}); + +add_task(async function search() { + await focusContentSearchBar(); + + let modifiers = {}; + ["altKey", "ctrlKey", "metaKey", "shiftKey"].forEach( + k => (modifiers[k] = true) + ); + + // Test typing a query and pressing enter. + let p = waitForSearch(); + await msg("key", { key: "x", waitForSuggestions: true }); + await msg("key", { key: "VK_RETURN", modifiers }); + let mesg = await p; + let eventData = { + engineName: TEST_ENGINE1.name, + searchString: "x", + healthReportKey: "test", + searchPurpose: "test", + originalEvent: modifiers, + }; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Test typing a query, then selecting a suggestion and pressing enter. + p = waitForSearch(); + await msg("key", { key: "x", waitForSuggestions: true }); + await msg("key", "VK_DOWN"); + await msg("key", "VK_DOWN"); + await msg("key", { key: "VK_RETURN", modifiers }); + mesg = await p; + eventData.searchString = "xfoo"; + eventData.engineName = TEST_ENGINE1.name; + eventData.selection = { + index: 1, + kind: "key", + }; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Test typing a query, then selecting a one-off button and pressing enter. + p = waitForSearch(); + await msg("key", { key: "x", waitForSuggestions: true }); + await msg("key", "VK_UP"); + await msg("key", "VK_UP"); + await msg("key", { key: "VK_RETURN", modifiers }); + mesg = await p; + delete eventData.selection; + eventData.searchString = "x"; + eventData.engineName = TEST_ENGINE2.name; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Test typing a query and clicking the search engine header. + p = waitForSearch(); + modifiers.button = 0; + await msg("key", { key: "x", waitForSuggestions: true }); + await msg("mousemove", -1); + await msg("click", { eltIdx: -1, modifiers }); + mesg = await p; + eventData.originalEvent = modifiers; + eventData.engineName = TEST_ENGINE1.name; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Test typing a query and then clicking a suggestion. + await msg("key", { key: "x", waitForSuggestions: true }); + p = waitForSearch(); + await msg("mousemove", 1); + await msg("click", { eltIdx: 1, modifiers }); + mesg = await p; + eventData.searchString = "xfoo"; + eventData.selection = { + index: 1, + kind: "mouse", + }; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Test typing a query and then clicking a one-off button. + await msg("key", { key: "x", waitForSuggestions: true }); + p = waitForSearch(); + await msg("mousemove", 3); + await msg("click", { eltIdx: 3, modifiers }); + mesg = await p; + eventData.searchString = "x"; + eventData.engineName = TEST_ENGINE2.name; + delete eventData.selection; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Test selecting a suggestion, then clicking a one-off without deselecting the + // suggestion, using the keyboard. + delete modifiers.button; + await msg("key", { key: "x", waitForSuggestions: true }); + p = waitForSearch(); + await msg("key", "VK_DOWN"); + await msg("key", "VK_DOWN"); + await msg("key", "VK_TAB"); + await msg("key", { key: "VK_RETURN", modifiers }); + mesg = await p; + eventData.searchString = "xfoo"; + eventData.selection = { + index: 1, + kind: "key", + }; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Test searching when using IME composition. + let state = await msg("startComposition", { data: "" }); + checkState(state, "", [], -1); + state = await msg("changeComposition", { + data: "x", + waitForSuggestions: true, + }); + checkState( + state, + "x", + [ + { str: "x", type: "formHistory" }, + { str: "xfoo", type: "formHistory" }, + "xbar", + ], + -1 + ); + await msg("commitComposition"); + delete modifiers.button; + p = waitForSearch(); + await msg("key", { key: "VK_RETURN", modifiers }); + mesg = await p; + eventData.searchString = "x"; + eventData.originalEvent = modifiers; + eventData.engineName = TEST_ENGINE1.name; + delete eventData.selection; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + state = await msg("startComposition", { data: "" }); + checkState(state, "", [], -1); + state = await msg("changeComposition", { + data: "x", + waitForSuggestions: true, + }); + checkState( + state, + "x", + [ + { str: "x", type: "formHistory" }, + { str: "xfoo", type: "formHistory" }, + "xbar", + ], + -1 + ); + + // Mouse over the first suggestion. + state = await msg("mousemove", 0); + checkState( + state, + "x", + [ + { str: "x", type: "formHistory" }, + { str: "xfoo", type: "formHistory" }, + "xbar", + ], + 0 + ); + + // Mouse over the second suggestion. + state = await msg("mousemove", 1); + checkState( + state, + "x", + [ + { str: "x", type: "formHistory" }, + { str: "xfoo", type: "formHistory" }, + "xbar", + ], + 1 + ); + + modifiers.button = 0; + p = waitForSearch(); + await msg("click", { eltIdx: 1, modifiers }); + mesg = await p; + eventData.searchString = "xfoo"; + eventData.originalEvent = modifiers; + eventData.selection = { + index: 1, + kind: "mouse", + }; + SimpleTest.isDeeply(eventData, mesg, "Search event data"); + + await promiseTab(); + await focusContentSearchBar(); + + // Remove form history entries. + // Wait for Satchel. + let observePromise = new Promise(resolve => { + let historyCount = 2; + Services.obs.addObserver(function onRemove(subj, topic, data) { + if (data == "formhistory-remove") { + if (--historyCount) { + return; + } + Services.obs.removeObserver(onRemove, "satchel-storage-changed"); + executeSoon(resolve); + } + }, "satchel-storage-changed"); + }); + + await msg("key", { key: "x", waitForSuggestions: true }); + await msg("key", "VK_DOWN"); + await msg("key", "VK_DOWN"); + await msg("key", "VK_DELETE"); + await msg("key", "VK_DOWN"); + await msg("key", "VK_DELETE"); + await observePromise; + + await msg("reset"); + state = await msg("key", { key: "x", waitForSuggestions: true }); + checkState(state, "x", ["xfoo", "xbar"], -1); + + await promiseTab(); + await focusContentSearchBar(); + await msg("reset"); +}); + +add_task(async function settings() { + await focusContentSearchBar(); + await msg("key", { key: "VK_DOWN", waitForSuggestions: true }); + await msg("key", "VK_UP"); + let p = waitForSearchSettings(); + await msg("key", "VK_RETURN"); + await p; + + await msg("reset"); +}); + +add_task(async function cleanup() { + Services.search.restoreDefaultEngines(); +}); + +function checkState( + actualState, + expectedInputVal, + expectedSuggestions, + expectedSelectedIdx, + expectedSelectedButtonIdx +) { + expectedSuggestions = expectedSuggestions.map(sugg => { + return typeof sugg == "object" + ? sugg + : { + str: sugg, + type: "remote", + }; + }); + + if (expectedSelectedIdx == -1 && expectedSelectedButtonIdx != undefined) { + expectedSelectedIdx = + expectedSuggestions.length + expectedSelectedButtonIdx; + } + + let expectedState = { + selectedIndex: expectedSelectedIdx, + numSuggestions: expectedSuggestions.length, + suggestionAtIndex: expectedSuggestions.map(s => s.str), + isFormHistorySuggestionAtIndex: expectedSuggestions.map( + s => s.type == "formHistory" + ), + + tableHidden: !expectedSuggestions.length, + + inputValue: expectedInputVal, + ariaExpanded: !expectedSuggestions.length ? "false" : "true", + }; + if (expectedSelectedButtonIdx != undefined) { + expectedState.selectedButtonIndex = expectedSelectedButtonIdx; + } else if (expectedSelectedIdx < expectedSuggestions.length) { + expectedState.selectedButtonIndex = -1; + } else { + expectedState.selectedButtonIndex = + expectedSelectedIdx - expectedSuggestions.length; + } + + SimpleTest.isDeeply(actualState, expectedState, "State"); +} + +var gMsgMan; + +async function promiseTab() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + registerCleanupFunction(() => BrowserTestUtils.removeTab(tab)); + + let loadedPromise = BrowserTestUtils.firstBrowserLoaded(window); + openTrustedLinkIn("about:test-about-content-search-ui", "current"); + await loadedPromise; +} diff --git a/browser/components/search/test/browser/browser_contentSearchUI_default.js b/browser/components/search/test/browser/browser_contentSearchUI_default.js new file mode 100644 index 0000000000..c69326262a --- /dev/null +++ b/browser/components/search/test/browser/browser_contentSearchUI_default.js @@ -0,0 +1,210 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const TEST_ENGINE_NAME = "searchSuggestionEngine"; +const HANDOFF_PREF = + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar"; + +let extension; +let defaultEngine; +let addedEngine; + +add_setup(async function () { + // Disable window occlusion. Bug 1733955 + if (navigator.platform.indexOf("Win") == 0) { + await SpecialPowers.pushPrefEnv({ + set: [["widget.windows.window_occlusion_tracking.enabled", false]], + }); + } + + defaultEngine = await Services.search.getDefault(); + + extension = await SearchTestUtils.installSearchExtension({ + id: TEST_ENGINE_NAME, + name: TEST_ENGINE_NAME, + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }); + + addedEngine = await Services.search.getEngineByName(TEST_ENGINE_NAME); + + // Enable suggestions in this test. Otherwise, the string in the content + // search box changes. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + + registerCleanupFunction(async () => { + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); +}); + +async function ensureIcon(tab, expectedIcon) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [expectedIcon], + async function (icon) { + await ContentTaskUtils.waitForCondition(() => !content.document.hidden); + + let computedStyle = content.window.getComputedStyle( + content.document.body + ); + await ContentTaskUtils.waitForCondition( + () => computedStyle.getPropertyValue("--newtab-search-icon") != "null", + "Search Icon not set." + ); + + Assert.equal( + computedStyle.getPropertyValue("--newtab-search-icon"), + `url(${icon})`, + "Should have the expected icon" + ); + } + ); +} + +async function ensurePlaceholder(tab, expectedId, expectedEngine) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [expectedId, expectedEngine], + async function (id, engine) { + await ContentTaskUtils.waitForCondition(() => !content.document.hidden); + + await ContentTaskUtils.waitForCondition( + () => content.document.querySelector(".search-handoff-button"), + "l10n ID not set." + ); + let buttonNode = content.document.querySelector(".search-handoff-button"); + let expectedAttributes = { id, args: engine ? { engine } : null }; + Assert.deepEqual( + content.document.l10n.getAttributes(buttonNode), + expectedAttributes, + "Expected updated l10n ID and args." + ); + } + ); +} + +async function runNewTabTest(isHandoff) { + let tab = await BrowserTestUtils.openNewForegroundTab({ + url: "about:newtab", + gBrowser, + waitForLoad: false, + }); + + let engineIcon = defaultEngine.getIconURLBySize(16, 16); + + await ensureIcon(tab, engineIcon); + if (isHandoff) { + await ensurePlaceholder( + tab, + "newtab-search-box-handoff-input", + Services.search.defaultEngine.name + ); + } + + await Services.search.setDefault( + addedEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + // We only show the engine's own icon for app provided engines, otherwise show + // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19 + await ensureIcon(tab, "chrome://global/skin/icons/search-glass.svg"); + if (isHandoff) { + await ensurePlaceholder(tab, "newtab-search-box-handoff-input-no-engine"); + } + + // Disable suggestions in the Urlbar. This should update the placeholder + // string since handoff will now enter search mode. + if (isHandoff) { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + await ensurePlaceholder(tab, "newtab-search-box-input"); + await SpecialPowers.popPrefEnv(); + } + + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_content_search_attributes() { + await SpecialPowers.pushPrefEnv({ + set: [[HANDOFF_PREF, true]], + }); + + await runNewTabTest(true); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_content_search_attributes_no_handoff() { + await SpecialPowers.pushPrefEnv({ + set: [[HANDOFF_PREF, false]], + }); + + await runNewTabTest(false); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_content_search_attributes_in_private_window() { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + waitForTabURL: "about:privatebrowsing", + }); + let tab = win.gBrowser.selectedTab; + + let engineIcon = defaultEngine.getIconURLBySize(16, 16); + + await ensureIcon(tab, engineIcon); + await ensurePlaceholder( + tab, + "about-private-browsing-handoff", + Services.search.defaultEngine.name + ); + + await Services.search.setDefault( + addedEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + // We only show the engine's own icon for app provided engines, otherwise show + // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19 + await ensureIcon(tab, "chrome://global/skin/icons/search-glass.svg"); + await ensurePlaceholder(tab, "about-private-browsing-handoff-no-engine"); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", false]], + }); + await ensurePlaceholder(tab, "about-private-browsing-search-btn"); + await SpecialPowers.popPrefEnv(); + + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_content_search_permanent_private_browsing() { + await SpecialPowers.pushPrefEnv({ + set: [ + [HANDOFF_PREF, true], + ["browser.privatebrowsing.autostart", true], + ], + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + await runNewTabTest(true); + await BrowserTestUtils.closeWindow(win); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/browser_contextSearchTabPosition.js b/browser/components/search/test/browser/browser_contextSearchTabPosition.js new file mode 100644 index 0000000000..345167c5b8 --- /dev/null +++ b/browser/components/search/test/browser/browser_contextSearchTabPosition.js @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +let engine; + +add_setup(async function () { + engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine.xml", + setAsDefault: true, + }); +}); + +add_task(async function test() { + let histogramKey = "other-" + engine.name + ".contextmenu"; + let numSearchesBefore = 0; + + try { + let hs = Services.telemetry + .getKeyedHistogramById("SEARCH_COUNTS") + .snapshot(); + if (histogramKey in hs) { + numSearchesBefore = hs[histogramKey].sum; + } + } catch (ex) { + // No searches performed yet, not a problem, |numSearchesBefore| is 0. + } + + let tabs = []; + let tabsLoadedDeferred = new Deferred(); + + function tabAdded(event) { + let tab = event.target; + tabs.push(tab); + + // We wait for the blank tab and the two context searches tabs to open. + if (tabs.length == 3) { + tabsLoadedDeferred.resolve(); + } + } + + let container = gBrowser.tabContainer; + container.addEventListener("TabOpen", tabAdded); + + BrowserTestUtils.addTab(gBrowser, "about:blank"); + BrowserSearch.loadSearchFromContext( + "mozilla", + false, + Services.scriptSecurityManager.getSystemPrincipal(), + Services.scriptSecurityManager.getSystemPrincipal().csp, + new MouseEvent("click") + ); + BrowserSearch.loadSearchFromContext( + "firefox", + false, + Services.scriptSecurityManager.getSystemPrincipal(), + Services.scriptSecurityManager.getSystemPrincipal().csp, + new MouseEvent("click") + ); + + // Wait for all the tabs to open. + await tabsLoadedDeferred.promise; + + is(tabs[0], gBrowser.tabs[3], "blank tab has been pushed to the end"); + is( + tabs[1], + gBrowser.tabs[1], + "first search tab opens next to the current tab" + ); + is( + tabs[2], + gBrowser.tabs[2], + "second search tab opens next to the first search tab" + ); + + container.removeEventListener("TabOpen", tabAdded); + tabs.forEach(gBrowser.removeTab, gBrowser); + + // Make sure that the context searches are correctly recorded in telemetry. + // Telemetry is not updated synchronously here, we must wait for it. + await TestUtils.waitForCondition(() => { + let hs = Services.telemetry + .getKeyedHistogramById("SEARCH_COUNTS") + .snapshot(); + return histogramKey in hs && hs[histogramKey].sum == numSearchesBefore + 2; + }, "The histogram must contain the correct search count"); +}); + +function Deferred() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); +} diff --git a/browser/components/search/test/browser/browser_contextmenu.js b/browser/components/search/test/browser/browser_contextmenu.js new file mode 100644 index 0000000000..67ba48da72 --- /dev/null +++ b/browser/components/search/test/browser/browser_contextmenu.js @@ -0,0 +1,249 @@ +/* Any copyright is dedicated to the Public Domain. + * * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* + * Test searching for the selected text using the context menu + */ + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +const ENGINE_NAME = "mozSearch"; +const PRIVATE_ENGINE_NAME = "mozPrivateSearch"; +const ENGINE_DATA = new Map([ + [ + ENGINE_NAME, + "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs", + ], + [PRIVATE_ENGINE_NAME, "https://example.com:443/browser/"], +]); + +let engine; +let privateEngine; +let extensions = []; +let oldDefaultEngine; +let oldDefaultPrivateEngine; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault", true], + ["browser.search.separatePrivateDefault.ui.enabled", true], + ], + }); + + await Services.search.init(); + + for (let [name, search_url] of ENGINE_DATA) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name, + search_url, + params: [ + { + name: "test", + value: "{searchTerms}", + }, + ], + }, + }, + }, + }); + + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + extensions.push(extension); + } + + engine = await Services.search.getEngineByName(ENGINE_NAME); + Assert.ok(engine, "Got a search engine"); + oldDefaultEngine = await Services.search.getDefault(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + privateEngine = await Services.search.getEngineByName(PRIVATE_ENGINE_NAME); + Assert.ok(privateEngine, "Got a search engine"); + oldDefaultPrivateEngine = await Services.search.getDefaultPrivate(); + await Services.search.setDefaultPrivate( + privateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +}); + +async function checkContextMenu( + win, + expectedName, + expectedBaseUrl, + expectedPrivateName +) { + let contextMenu = win.document.getElementById("contentAreaContextMenu"); + Assert.ok(contextMenu, "Got context menu XUL"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + win.gBrowser, + "https://example.com/browser/browser/components/search/test/browser/test_search.html" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () { + return new Promise(resolve => { + content.document.addEventListener( + "selectionchange", + function () { + resolve(); + }, + { once: true } + ); + content.document.getSelection().selectAllChildren(content.document.body); + }); + }); + + let eventDetails = { type: "contextmenu", button: 2 }; + + let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "body", + eventDetails, + win.gBrowser.selectedBrowser + ); + await popupPromise; + + info("checkContextMenu"); + let searchItem = contextMenu.getElementsByAttribute( + "id", + "context-searchselect" + )[0]; + Assert.ok(searchItem, "Got search context menu item"); + Assert.equal( + searchItem.label, + "Search " + expectedName + " for \u201ctest%20search\u201d", + "Check context menu label" + ); + Assert.equal( + searchItem.disabled, + false, + "Check that search context menu item is enabled" + ); + + let loaded = BrowserTestUtils.waitForNewTab( + win.gBrowser, + expectedBaseUrl + "?test=test%2520search", + true + ); + contextMenu.activateItem(searchItem); + let searchTab = await loaded; + let browser = win.gBrowser.selectedBrowser; + await SpecialPowers.spawn(browser, [], async function () { + Assert.ok( + !/error/.test(content.document.body.innerHTML), + "Ensure there were no errors loading the search page" + ); + }); + + searchItem = contextMenu.getElementsByAttribute( + "id", + "context-searchselect-private" + )[0]; + Assert.ok(searchItem, "Got search in private window context menu item"); + if (PrivateBrowsingUtils.isWindowPrivate(win)) { + Assert.ok(searchItem.hidden, "Search in private window should be hidden"); + } else { + let expectedLabel = expectedPrivateName + ? "Search with " + expectedPrivateName + " in a Private Window" + : "Search in a Private Window"; + Assert.equal(searchItem.label, expectedLabel, "Check context menu label"); + Assert.equal( + searchItem.disabled, + false, + "Check that search context menu item is enabled" + ); + } + + contextMenu.hidePopup(); + + BrowserTestUtils.removeTab(searchTab); + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_normalWindow() { + await checkContextMenu( + window, + ENGINE_NAME, + "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs", + PRIVATE_ENGINE_NAME + ); +}); + +add_task(async function test_privateWindow() { + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + }); + + await checkContextMenu( + win, + PRIVATE_ENGINE_NAME, + "https://example.com/browser/" + ); +}); + +add_task(async function test_normalWindow_sameDefaults() { + // Set the private default engine to be the same as the current default engine + // in 'normal' mode. + await Services.search.setDefaultPrivate( + await Services.search.getDefault(), + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + await checkContextMenu( + window, + ENGINE_NAME, + "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs" + ); +}); + +add_task(async function test_privateWindow_no_separate_engine() { + await SpecialPowers.pushPrefEnv({ + set: [ + // We want select events to be fired. + ["browser.search.separatePrivateDefault", false], + ], + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + registerCleanupFunction(async () => { + await BrowserTestUtils.closeWindow(win); + }); + + await checkContextMenu( + win, + ENGINE_NAME, + "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs" + ); +}); + +// We can't do the unload within registerCleanupFunction as that's too late for +// the test to be happy. Do it into a cleanup "test" here instead. +add_task(async function cleanup() { + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + oldDefaultPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.removeEngine(engine); + await Services.search.removeEngine(privateEngine); + + for (let extension of extensions) { + await extension.unload(); + } +}); diff --git a/browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js b/browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js new file mode 100644 index 0000000000..ed3fd6901d --- /dev/null +++ b/browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js @@ -0,0 +1,183 @@ +/* Any copyright is dedicated to the Public Domain. + * * http://creativecommons.org/publicdomain/zero/1.0/ */ +/* + * Test searching for the selected text using the context menu + */ + +const { SearchTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" +); + +SearchTestUtils.init(this); + +const ENGINE_NAME = "mozSearch"; +const ENGINE_URL = + "https://example.com/browser/browser/components/search/test/browser/mozsearch.sjs"; + +add_setup(async function () { + await Services.search.init(); + + await SearchTestUtils.installSearchExtension( + { + name: ENGINE_NAME, + search_url: ENGINE_URL, + search_url_get_params: "test={searchTerms}", + }, + { setAsDefault: true } + ); +}); + +async function openNewSearchTab(event_args, expect_new_window = false) { + // open context menu with right click + let contextMenu = document.getElementById("contentAreaContextMenu"); + + let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupPromise; + + let searchItem = contextMenu.getElementsByAttribute( + "id", + "context-searchselect" + )[0]; + + // open new search tab with desired modifiers + let searchTabPromise; + if (expect_new_window) { + searchTabPromise = BrowserTestUtils.waitForNewWindow({ + url: ENGINE_URL + "?test=test%2520search", + }); + } else { + searchTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + ENGINE_URL + "?test=test%2520search", + true + ); + } + + if ("button" in event_args) { + // Bug 1704879: activateItem does not currently support button + EventUtils.synthesizeMouseAtCenter(searchItem, event_args); + } else { + contextMenu.activateItem(searchItem, event_args); + } + + if (expect_new_window) { + let win = await searchTabPromise; + return win.gBrowser.selectedTab; + } + return searchTabPromise; +} + +add_task(async function test_whereToOpenLink() { + // open search test page and select search text + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/browser/browser/components/search/test/browser/test_search.html" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () { + return new Promise(resolve => { + content.document.addEventListener( + "selectionchange", + function () { + resolve(); + }, + { once: true } + ); + content.document.getSelection().selectAllChildren(content.document.body); + }); + }); + + // check where context search opens for different buttons/modifiers + let searchTab = await openNewSearchTab({}); + is( + searchTab, + gBrowser.selectedTab, + "Search tab is opened in foreground (no modifiers)" + ); + BrowserTestUtils.removeTab(searchTab); + + // TODO bug 1704883: Re-enable this subtest. Native context menus on macOS do + // not yet support alternate mouse buttons. + if ( + !AppConstants.platform == "macosx" || + !Services.prefs.getBoolPref("widget.macos.native-context-menus", false) + ) { + searchTab = await openNewSearchTab({ button: 1 }); + isnot( + searchTab, + gBrowser.selectedTab, + "Search tab is opened in background (middle mouse)" + ); + BrowserTestUtils.removeTab(searchTab); + } + + searchTab = await openNewSearchTab({ ctrlKey: true }); + isnot( + searchTab, + gBrowser.selectedTab, + "Search tab is opened in background (Ctrl)" + ); + BrowserTestUtils.removeTab(searchTab); + + let current_browser = gBrowser.selectedBrowser; + searchTab = await openNewSearchTab({ shiftKey: true }, true); + isnot( + current_browser, + gBrowser.getBrowserForTab(searchTab), + "Search tab is opened in new window (Shift)" + ); + BrowserTestUtils.removeTab(searchTab); + + info("flipping browser.search.context.loadInBackground and re-checking"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.context.loadInBackground", true]], + }); + + searchTab = await openNewSearchTab({}); + isnot( + searchTab, + gBrowser.selectedTab, + "Search tab is opened in background (no modifiers)" + ); + BrowserTestUtils.removeTab(searchTab); + + // TODO bug 1704883: Re-enable this subtest. Native context menus on macOS do + // not yet support alternate mouse buttons. + if ( + !AppConstants.platform == "macosx" || + !Services.prefs.getBoolPref("widget.macos.native-context-menus", false) + ) { + searchTab = await openNewSearchTab({ button: 1 }); + is( + searchTab, + gBrowser.selectedTab, + "Search tab is opened in foreground (middle mouse)" + ); + BrowserTestUtils.removeTab(searchTab); + } + + searchTab = await openNewSearchTab({ ctrlKey: true }); + is( + searchTab, + gBrowser.selectedTab, + "Search tab is opened in foreground (Ctrl)" + ); + BrowserTestUtils.removeTab(searchTab); + + current_browser = gBrowser.selectedBrowser; + searchTab = await openNewSearchTab({ shiftKey: true }, true); + isnot( + current_browser, + gBrowser.getBrowserForTab(searchTab), + "Search tab is opened in new window (Shift)" + ); + BrowserTestUtils.removeTab(searchTab); + + // cleanup + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_defaultPrivate_nimbus.js b/browser/components/search/test/browser/browser_defaultPrivate_nimbus.js new file mode 100644 index 0000000000..ce5acc91a0 --- /dev/null +++ b/browser/components/search/test/browser/browser_defaultPrivate_nimbus.js @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { ExperimentAPI } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", +}); + +const CONFIG_DEFAULT = [ + { + webExtension: { id: "basic@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, + { + webExtension: { id: "private@search.mozilla.org" }, + appliesTo: [ + { + experiment: "testing", + included: { everywhere: true }, + }, + ], + defaultPrivate: "yes", + }, +]; + +SearchTestUtils.init(this); + +add_setup(async () => { + // Use engines in test directory + let searchExtensions = getChromeDir(getResolvedURI(gTestPath)); + searchExtensions.append("search-engines"); + await SearchTestUtils.useMochitestEngines(searchExtensions); + + // Current default values. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", false], + ["browser.search.separatePrivateDefault.urlbarResult.enabled", false], + ["browser.search.separatePrivateDefault", true], + ["browser.urlbar.suggest.searches", true], + ], + }); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT); + + registerCleanupFunction(async () => { + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + }); +}); + +add_task(async function test_nimbus_experiment() { + Assert.equal( + Services.search.defaultPrivateEngine.name, + "basic", + "Should have basic as private default while not in experiment" + ); + await ExperimentAPI.ready(); + + let reloadObserved = + SearchTestUtils.promiseSearchNotification("engines-reloaded"); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "searchConfiguration", + value: { + seperatePrivateDefaultUIEnabled: true, + seperatePrivateDefaultUrlbarResultEnabled: false, + experiment: "testing", + }, + }); + await reloadObserved; + Assert.equal( + Services.search.defaultPrivateEngine.name, + "private", + "Should have private as private default while in experiment" + ); + reloadObserved = + SearchTestUtils.promiseSearchNotification("engines-reloaded"); + await doExperimentCleanup(); + await reloadObserved; + Assert.equal( + Services.search.defaultPrivateEngine.name, + "basic", + "Should turn off private default and restore default engine after experiment" + ); +}); + +add_task(async function test_nimbus_experiment_urlbar_result_enabled() { + Assert.equal( + Services.search.defaultPrivateEngine.name, + "basic", + "Should have basic as private default while not in experiment" + ); + await ExperimentAPI.ready(); + + let reloadObserved = + SearchTestUtils.promiseSearchNotification("engines-reloaded"); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "searchConfiguration", + value: { + seperatePrivateDefaultUIEnabled: true, + seperatePrivateDefaultUrlbarResultEnabled: true, + experiment: "testing", + }, + }); + await reloadObserved; + Assert.equal( + Services.search.separatePrivateDefaultUrlbarResultEnabled, + true, + "Should have set the urlbar result enabled value to true" + ); + reloadObserved = + SearchTestUtils.promiseSearchNotification("engines-reloaded"); + await doExperimentCleanup(); + await reloadObserved; + Assert.equal( + Services.search.defaultPrivateEngine.name, + "basic", + "Should turn off private default and restore default engine after experiment" + ); +}); + +add_task(async function test_non_experiment_prefs() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault.ui.enabled", false]], + }); + let uiPref = () => + Services.prefs.getBoolPref( + "browser.search.separatePrivateDefault.ui.enabled" + ); + Assert.equal(uiPref(), false, "defaulted false"); + await ExperimentAPI.ready(); + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "privatesearch", + value: { + seperatePrivateDefaultUIEnabled: true, + }, + }); + Assert.equal(uiPref(), false, "Pref did not change without experiment"); + await doExperimentCleanup(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/browser_google_behavior.js b/browser/components/search/test/browser/browser_google_behavior.js new file mode 100644 index 0000000000..1d58ac77ee --- /dev/null +++ b/browser/components/search/test/browser/browser_google_behavior.js @@ -0,0 +1,215 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test Google search plugin URLs + * TODO: This test is a near duplicate of browser_searchEngine_behaviors.js but + * specific to Google. This is required due to bug 1315953. + * + * Note: Although we have tests for codes in + * toolkit/components/tests/xpcshell/searchconfigs, we also need this test as an + * integration test to check the search service to selector integration is + * working correctly (especially the ESR codes). + */ + +"use strict"; + +let searchEngineDetails = [ + { + alias: "g", + codes: { + context: "", + keyword: "", + newTab: "", + submission: "", + }, + name: "Google", + }, +]; + +let region = Services.prefs.getCharPref("browser.search.region"); +let code = ""; +switch (region) { + case "US": + if (SearchUtils.MODIFIED_APP_CHANNEL == "esr") { + code = "firefox-b-1-e"; + } else { + code = "firefox-b-1-d"; + } + break; + case "DE": + if (SearchUtils.MODIFIED_APP_CHANNEL == "esr") { + code = "firefox-b-e"; + } else { + code = "firefox-b-d"; + } + break; +} + +if (code) { + let codes = searchEngineDetails[0].codes; + codes.context = code; + codes.newTab = code; + codes.submission = code; + codes.keyword = code; +} + +function promiseContentSearchReady(browser) { + return SpecialPowers.spawn(browser, [], async function (args) { + return new Promise(resolve => { + SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + if (content.wrappedJSObject.gContentSearchController) { + let searchController = content.wrappedJSObject.gContentSearchController; + if (searchController.defaultEngine) { + resolve(); + } + } + + content.addEventListener( + "ContentSearchService", + function listener(aEvent) { + if (aEvent.detail.type == "State") { + content.removeEventListener("ContentSearchService", listener); + resolve(); + } + } + ); + }); + }); +} + +add_setup(async function () { + await Services.search.init(); +}); + +for (let engine of searchEngineDetails) { + add_task(async function () { + let previouslySelectedEngine = Services.search.defaultEngine; + + registerCleanupFunction(function () { + Services.search.defaultEngine = previouslySelectedEngine; + }); + + await testSearchEngine(engine); + }); +} + +async function testSearchEngine(engineDetails) { + let engine = Services.search.getEngineByName(engineDetails.name); + Assert.ok(engine, `${engineDetails.name} is installed`); + + Services.search.defaultEngine = engine; + engine.alias = engineDetails.alias; + + // Test search URLs (including purposes). + let url = engine.getSubmission("foo").uri.spec; + let urlParams = new URLSearchParams(url.split("?")[1]); + Assert.equal(urlParams.get("q"), "foo", "Check search URL for 'foo'"); + + let engineTests = [ + { + name: "context menu search", + code: engineDetails.codes.context, + run() { + // Simulate a contextmenu search + // FIXME: This is a bit "low-level"... + BrowserSearch._loadSearch( + "foo", + false, + false, + "contextmenu", + Services.scriptSecurityManager.getSystemPrincipal() + ); + }, + }, + { + name: "keyword search", + code: engineDetails.codes.keyword, + run() { + gURLBar.value = "? foo"; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + }, + }, + { + name: "keyword search with alias", + code: engineDetails.codes.keyword, + run() { + gURLBar.value = `${engineDetails.alias} foo`; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + }, + }, + { + name: "search bar search", + code: engineDetails.codes.submission, + async preTest() { + await gCUITestUtils.addSearchBar(); + }, + run() { + let sb = BrowserSearch.searchBar; + sb.focus(); + sb.value = "foo"; + EventUtils.synthesizeKey("KEY_Enter"); + }, + postTest() { + BrowserSearch.searchBar.value = ""; + gCUITestUtils.removeSearchBar(); + }, + }, + { + name: "new tab search", + code: engineDetails.codes.newTab, + async preTest(tab) { + let browser = tab.linkedBrowser; + BrowserTestUtils.loadURIString(browser, "about:newtab"); + await BrowserTestUtils.browserLoaded(browser, false, "about:newtab"); + + await promiseContentSearchReady(browser); + }, + async run(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], async function (args) { + let input = content.document.querySelector("input[id*=search-]"); + input.focus(); + input.value = "foo"; + }); + EventUtils.synthesizeKey("KEY_Enter"); + }, + }, + ]; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + for (let test of engineTests) { + info(`Running: ${test.name}`); + + if (test.preTest) { + await test.preTest(tab); + } + + let googleUrl = + "https://www.google.com/search?client=" + test.code + "&q=foo"; + let promises = [ + BrowserTestUtils.waitForDocLoadAndStopIt(googleUrl, tab), + BrowserTestUtils.browserStopped(tab.linkedBrowser, googleUrl, true), + ]; + + await test.run(tab); + + await Promise.all(promises); + + if (test.postTest) { + await test.postTest(tab); + } + } + + engine.alias = undefined; + BrowserTestUtils.removeTab(tab); +} diff --git a/browser/components/search/test/browser/browser_hiddenOneOffs_cleanup.js b/browser/components/search/test/browser/browser_hiddenOneOffs_cleanup.js new file mode 100644 index 0000000000..8be90b3288 --- /dev/null +++ b/browser/components/search/test/browser/browser_hiddenOneOffs_cleanup.js @@ -0,0 +1,117 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +const testPref = "Foo,FooDupe"; + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.search.hiddenOneOffs"); +}); + +add_task(async function test_remove() { + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine_dupe.xml", + }); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine.xml", + }); + Services.prefs.setCharPref("browser.search.hiddenOneOffs", testPref); + + info("Removing testEngine_dupe.xml"); + await Services.search.removeEngine( + Services.search.getEngineByName("FooDupe") + ); + + let hiddenOneOffs = Services.prefs + .getCharPref("browser.search.hiddenOneOffs") + .split(","); + + is( + hiddenOneOffs.length, + 1, + "hiddenOneOffs has the correct engine count post removal." + ); + is( + hiddenOneOffs.some(x => x == "FooDupe"), + false, + "Removed Engine is not in hiddenOneOffs after removal" + ); + is( + hiddenOneOffs.some(x => x == "Foo"), + true, + "Current hidden engine is not affected by removal." + ); + + info("Removing testEngine.xml"); + await Services.search.removeEngine(Services.search.getEngineByName("Foo")); + + is( + Services.prefs.getCharPref("browser.search.hiddenOneOffs"), + "", + "hiddenOneOffs is empty after removing all hidden engines." + ); +}); + +add_task(async function test_add() { + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine.xml", + }); + info("setting prefs to " + testPref); + Services.prefs.setCharPref("browser.search.hiddenOneOffs", testPref); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine_dupe.xml", + }); + let hiddenOneOffs = Services.prefs + .getCharPref("browser.search.hiddenOneOffs") + .split(","); + + is( + hiddenOneOffs.length, + 1, + "hiddenOneOffs has the correct number of hidden engines present post add." + ); + is( + hiddenOneOffs.some(x => x == "FooDupe"), + false, + "Added engine is not present in hidden list." + ); + is( + hiddenOneOffs.some(x => x == "Foo"), + true, + "Adding an engine does not remove engines from hidden list." + ); +}); + +add_task(async function test_diacritics() { + const diacritic_engine = "Foo \u2661"; + let { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" + ); + + Preferences.set("browser.search.hiddenOneOffs", diacritic_engine); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine_diacritics.xml", + }); + + let hiddenOneOffs = Preferences.get("browser.search.hiddenOneOffs").split( + "," + ); + is( + hiddenOneOffs.some(x => x == diacritic_engine), + false, + "Observer cleans up added hidden engines that include a diacritic." + ); + + Preferences.set("browser.search.hiddenOneOffs", diacritic_engine); + + info("Removing testEngine_diacritics.xml"); + await Services.search.removeEngine( + Services.search.getEngineByName(diacritic_engine) + ); + + hiddenOneOffs = Preferences.get("browser.search.hiddenOneOffs").split(","); + is( + hiddenOneOffs.some(x => x == diacritic_engine), + false, + "Observer cleans up removed hidden engines that include a diacritic." + ); +}); diff --git a/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js b/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js new file mode 100644 index 0000000000..a583aad7b3 --- /dev/null +++ b/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// Tests that keyboard navigation in the search panel works as designed. + +const searchPopup = document.getElementById("PopupSearchAutoComplete"); + +const diacritic_engine = "Foo \u2661"; + +var { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +let searchIcon; + +add_setup(async function () { + let searchbar = await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); + searchIcon = searchbar.querySelector(".searchbar-search-button"); + + let defaultEngine = await Services.search.getDefault(); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine_diacritics.xml", + }); + registerCleanupFunction(async () => { + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + Services.prefs.clearUserPref("browser.search.hiddenOneOffs"); + }); +}); + +add_task(async function test_hidden() { + Preferences.set("browser.search.hiddenOneOffs", diacritic_engine); + + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + await promise; + + ok( + !getOneOffs().some(x => x.getAttribute("tooltiptext") == diacritic_engine), + "Search engines with diacritics are hidden when added to hiddenOneOffs preference." + ); + + promise = promiseEvent(searchPopup, "popuphidden"); + info("Closing search panel"); + EventUtils.synthesizeKey("KEY_Escape"); + await promise; +}); + +add_task(async function test_shown() { + Preferences.set("browser.search.hiddenOneOffs", ""); + + let oneOffsContainer = searchPopup.searchOneOffsContainer; + let shownPromise = promiseEvent(searchPopup, "popupshown"); + let builtPromise = promiseEvent(oneOffsContainer, "rebuild"); + info("Opening search panel"); + + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + await Promise.all([shownPromise, builtPromise]); + + ok( + getOneOffs().some(x => x.getAttribute("tooltiptext") == diacritic_engine), + "Search engines with diacritics are shown when removed from hiddenOneOffs preference." + ); + + let promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; +}); diff --git a/browser/components/search/test/browser/browser_ime_composition.js b/browser/components/search/test/browser/browser_ime_composition.js new file mode 100644 index 0000000000..763885aad6 --- /dev/null +++ b/browser/components/search/test/browser/browser_ime_composition.js @@ -0,0 +1,77 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Tests ime composition handling on searchbar. + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + registerCleanupFunction(async function () { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_composition_with_focus() { + info("Open a page"); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com"); + + info("Focus on the search bar"); + const searchBarTextBox = BrowserSearch.searchBar.textbox; + EventUtils.synthesizeMouseAtCenter(searchBarTextBox, {}); + is( + document.activeElement, + BrowserSearch.searchBar.textbox, + "The text box of search bar has focus" + ); + + info("Do search with new tab"); + EventUtils.synthesizeKey("x"); + EventUtils.synthesizeKey("KEY_Enter", { altKey: true, type: "keydown" }); + is(gBrowser.tabs.length, 3, "Alt+Return key added new tab"); + await TestUtils.waitForCondition( + () => document.activeElement === gBrowser.selectedBrowser, + "Wait for focus to be moved to the browser" + ); + info("The focus is moved to the browser"); + + info("Focus on the search bar again"); + EventUtils.synthesizeMouseAtCenter(searchBarTextBox, {}); + is( + document.activeElement, + BrowserSearch.searchBar.textbox, + "The textbox of search bar has focus again" + ); + + info("Type some characters during composition"); + const string = "ex"; + EventUtils.synthesizeCompositionChange({ + composition: { + string, + clauses: [ + { + length: string.length, + attr: Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE, + }, + ], + }, + caret: { start: string.length, length: 0 }, + key: { key: string[string.length - 1] }, + }); + + info("Commit the composition"); + EventUtils.synthesizeComposition({ + type: "compositioncommitasis", + key: { key: "KEY_Enter" }, + }); + is( + document.activeElement, + BrowserSearch.searchBar.textbox, + "The search bar still has focus" + ); + + // Close all open tabs + await BrowserTestUtils.removeTab(gBrowser.tabs[2]); + await BrowserTestUtils.removeTab(gBrowser.tabs[1]); +}); diff --git a/browser/components/search/test/browser/browser_oneOffContextMenu.js b/browser/components/search/test/browser/browser_oneOffContextMenu.js new file mode 100644 index 0000000000..c036a5f007 --- /dev/null +++ b/browser/components/search/test/browser/browser_oneOffContextMenu.js @@ -0,0 +1,89 @@ +"use strict"; + +const TEST_ENGINE_NAME = "Foo"; +const TEST_ENGINE_BASENAME = "testEngine.xml"; + +let searchbar; +let searchIcon; + +add_setup(async function () { + searchbar = await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); + searchIcon = searchbar.querySelector(".searchbar-search-button"); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); +}); + +add_task(async function telemetry() { + let searchPopup = document.getElementById("PopupSearchAutoComplete"); + let oneOffInstance = searchPopup.oneOffButtons; + + let oneOffButtons = oneOffInstance.buttons; + + // Open the popup. + let shownPromise = promiseEvent(searchPopup, "popupshown"); + let builtPromise = promiseEvent(oneOffInstance, "rebuild"); + info("Opening search panel"); + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + await Promise.all([shownPromise, builtPromise]); + + // Get the one-off button for the test engine. + let oneOffButton; + for (let node of oneOffButtons.children) { + if (node.engine && node.engine.name == TEST_ENGINE_NAME) { + oneOffButton = node; + break; + } + } + Assert.notEqual( + oneOffButton, + undefined, + "One-off for test engine should exist" + ); + + // Open the context menu on the one-off. + let contextMenu = oneOffInstance.querySelector( + ".search-one-offs-context-menu" + ); + let promise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(oneOffButton, { + type: "contextmenu", + button: 2, + }); + await promise; + + // Click the Search in New Tab menu item. + let searchInNewTabMenuItem = contextMenu.querySelector( + ".search-one-offs-context-open-in-new-tab" + ); + promise = BrowserTestUtils.waitForNewTab(gBrowser); + contextMenu.activateItem(searchInNewTabMenuItem); + let tab = await promise; + + // By default the search will open in the background and the popup will stay open: + promise = promiseEvent(searchPopup, "popuphidden"); + info("Closing search panel"); + EventUtils.synthesizeKey("KEY_Escape"); + await promise; + + // Check the loaded tab. + Assert.equal( + tab.linkedBrowser.currentURI.spec, + "http://mochi.test:8888/browser/browser/components/search/test/browser/", + "Expected search tab should have loaded" + ); + + BrowserTestUtils.removeTab(tab); + + // Move the cursor out of the panel area to avoid messing with other tests. + await EventUtils.promiseNativeMouseEvent({ + type: "mousemove", + target: searchbar, + offsetX: 0, + offsetY: 0, + }); +}); diff --git a/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js b/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js new file mode 100644 index 0000000000..569e56b9ff --- /dev/null +++ b/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js @@ -0,0 +1,236 @@ +"use strict"; + +const TEST_ENGINE_NAME = "Foo"; +const TEST_ENGINE_BASENAME = "testEngine.xml"; +const SEARCHBAR_BASE_ID = "searchbar-engine-one-off-item-"; + +let originalEngine; +let originalPrivateEngine; + +async function resetEngines() { + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + originalPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +} + +registerCleanupFunction(resetEngines); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", true], + ["browser.search.widget.inNavBar", true], + ], + }); + originalEngine = await Services.search.getDefault(); + originalPrivateEngine = await Services.search.getDefaultPrivate(); + registerCleanupFunction(async () => { + await resetEngines(); + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + }); +}); + +async function testSearchBarChangeEngine(win, testPrivate, isPrivateWindow) { + info( + `Testing search bar with testPrivate: ${testPrivate} isPrivateWindow: ${isPrivateWindow}` + ); + + const searchPopup = win.document.getElementById("PopupSearchAutoComplete"); + const searchOneOff = searchPopup.oneOffButtons; + + // Ensure the engine is reset. + await resetEngines(); + + let oneOffButton = await openPopupAndGetEngineButton( + searchPopup, + searchOneOff, + SEARCHBAR_BASE_ID, + TEST_ENGINE_NAME + ); + + const contextMenu = searchOneOff.contextMenuPopup; + const setDefaultEngineMenuItem = searchOneOff.querySelector( + ".search-one-offs-context-set-default" + (testPrivate ? "-private" : "") + ); + + // Click the set default engine menu item. + let promise = promiseDefaultEngineChanged(testPrivate); + contextMenu.activateItem(setDefaultEngineMenuItem); + + // This also checks the engine correctly changed. + await promise; + + if (testPrivate == isPrivateWindow) { + let expectedName = originalEngine.name; + let expectedImage = originalEngine.iconURI.spec; + if (isPrivateWindow) { + expectedName = originalPrivateEngine.name; + expectedImage = originalPrivateEngine.iconURI.spec; + } + + Assert.equal( + oneOffButton.getAttribute("tooltiptext"), + expectedName, + "Should now have the original engine's name for the tooltip" + ); + Assert.equal( + oneOffButton.image, + expectedImage, + "Should now have the original engine's uri for the image" + ); + } + + await promiseClosePopup(searchPopup); +} + +add_task(async function test_searchBarChangeEngine() { + await testSearchBarChangeEngine(window, false, false); + await testSearchBarChangeEngine(window, true, false); +}); + +add_task(async function test_searchBarChangeEngine_privateWindow() { + const win = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await testSearchBarChangeEngine(win, true, true); + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Promises that an engine change has happened for the current engine, which + * has resulted in the test engine now being the current engine. + * + * @param {boolean} testPrivate + * Set to true if we're testing the private default engine. + * @returns {Promise} Resolved once the test engine is set as the current engine. + */ +function promiseDefaultEngineChanged(testPrivate) { + const expectedNotification = testPrivate + ? "engine-default-private" + : "engine-default"; + return new Promise(resolve => { + function observer(aSub, aTopic, aData) { + if (aData == expectedNotification) { + Assert.equal( + Services.search[ + testPrivate ? "defaultPrivateEngine" : "defaultEngine" + ].name, + TEST_ENGINE_NAME, + "defaultEngine set" + ); + Services.obs.removeObserver(observer, "browser-search-engine-modified"); + resolve(); + } + } + + Services.obs.addObserver(observer, "browser-search-engine-modified"); + }); +} + +/** + * Opens the specified search popup and gets the test engine from the + * one-off buttons. + * + * @param {object} popup The expected popup. + * @param {object} oneOffInstance The expected one-off instance for the popup. + * @param {string} baseId The expected string for the id of the current + * engine button, without the engine name. + * @param {string} engineName The engine name for finding the one-off button. + * @returns {object} Returns an object that represents the one off button for the + * test engine. + */ +async function openPopupAndGetEngineButton( + popup, + oneOffInstance, + baseId, + engineName +) { + const win = oneOffInstance.container.ownerGlobal; + // Open the popup. + win.gURLBar.blur(); + let shownPromise = promiseEvent(popup, "popupshown"); + let builtPromise = promiseEvent(oneOffInstance, "rebuild"); + let searchbar = win.document.getElementById("searchbar"); + let searchIcon = searchbar.querySelector(".searchbar-search-button"); + // Use the search icon to avoid hitting the network. + EventUtils.synthesizeMouseAtCenter(searchIcon, {}, win); + await Promise.all([shownPromise, builtPromise]); + + const contextMenu = oneOffInstance.contextMenuPopup; + let oneOffButton = oneOffInstance.buttons; + + // Get the one-off button for the test engine. + for ( + oneOffButton = oneOffButton.firstChild; + oneOffButton; + oneOffButton = oneOffButton.nextSibling + ) { + if ( + oneOffButton.nodeType == Node.ELEMENT_NODE && + oneOffButton.engine && + oneOffButton.engine.name == engineName + ) { + break; + } + } + + Assert.notEqual( + oneOffButton, + undefined, + "One-off for test engine should exist" + ); + Assert.equal( + oneOffButton.getAttribute("tooltiptext"), + engineName, + "One-off should have the tooltip set to the engine name" + ); + + Assert.ok( + oneOffButton.id.startsWith(baseId + "engine-"), + "Should have an appropriate id" + ); + + // Open the context menu on the one-off. + let promise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter( + oneOffButton, + { + type: "contextmenu", + button: 2, + }, + win + ); + await promise; + + return oneOffButton; +} + +/** + * Closes the popup and moves the mouse away from it. + * + * @param {Button} popup The popup to close. + */ +async function promiseClosePopup(popup) { + // close the panel using the escape key. + let promise = promiseEvent(popup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape", {}, popup.ownerGlobal); + await promise; + + // Move the cursor out of the panel area to avoid messing with other tests. + EventUtils.synthesizeNativeMouseEvent({ + type: "mousemove", + target: popup, + offsetX: 0, + offsetY: 0, + win: popup.ownerGlobal, + }); +} diff --git a/browser/components/search/test/browser/browser_private_search_perwindowpb.js b/browser/components/search/test/browser/browser_private_search_perwindowpb.js new file mode 100644 index 0000000000..b1ca3cb962 --- /dev/null +++ b/browser/components/search/test/browser/browser_private_search_perwindowpb.js @@ -0,0 +1,84 @@ +// This test performs a search in a public window, then a different +// search in a private window, and then checks in the public window +// whether there is an autocomplete entry for the private search. + +add_task(async function test_setup() { + await gCUITestUtils.addSearchBar(); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "426329.xml", + setAsDefault: true, + }); + + registerCleanupFunction(async () => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function () { + let windowsToClose = []; + + function performSearch(aWin, aIsPrivate) { + let searchBar = aWin.BrowserSearch.searchBar; + ok(searchBar, "got search bar"); + + let loadPromise = BrowserTestUtils.browserLoaded( + aWin.gBrowser.selectedBrowser + ); + + searchBar.value = aIsPrivate ? "private test" : "public test"; + searchBar.focus(); + EventUtils.synthesizeKey("VK_RETURN", {}, aWin); + + return loadPromise; + } + + async function testOnWindow(aIsPrivate) { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: aIsPrivate, + }); + await SimpleTest.promiseFocus(win); + windowsToClose.push(win); + return win; + } + + let newWindow = await testOnWindow(false); + await performSearch(newWindow, false); + + newWindow = await testOnWindow(true); + await performSearch(newWindow, true); + + newWindow = await testOnWindow(false); + + let searchBar = newWindow.BrowserSearch.searchBar; + searchBar.value = "p"; + searchBar.focus(); + + let popup = searchBar.textbox.popup; + let popupPromise = BrowserTestUtils.waitForEvent(popup, "popupshown"); + searchBar.textbox.showHistoryPopup(); + await popupPromise; + + let entries = getMenuEntries(searchBar); + for (let i = 0; i < entries.length; i++) { + isnot( + entries[i], + "private test", + "shouldn't see private autocomplete entries" + ); + } + + searchBar.textbox.toggleHistoryPopup(); + searchBar.value = ""; + + windowsToClose.forEach(function (win) { + win.close(); + }); +}); + +function getMenuEntries(searchBar) { + // Could perhaps pull values directly from the controller, but it seems + // more reliable to test the values that are actually in the richlistbox? + return Array.from(searchBar.textbox.popup.richlistbox.itemChildren, item => + item.getAttribute("ac-value") + ); +} diff --git a/browser/components/search/test/browser/browser_rich_suggestions.js b/browser/components/search/test/browser/browser_rich_suggestions.js new file mode 100644 index 0000000000..b92cdb5a6a --- /dev/null +++ b/browser/components/search/test/browser/browser_rich_suggestions.js @@ -0,0 +1,110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const CONFIG_DEFAULT = [ + { + webExtension: { id: "basic@search.mozilla.org" }, + urls: { + trending: { + fullPath: + "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs?richsuggestions=true", + query: "", + }, + }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, +]; + +SearchTestUtils.init(this); + +add_setup(async () => { + // Use engines in test directory + let searchExtensions = getChromeDir(getResolvedURI(gTestPath)); + searchExtensions.append("search-engines"); + await SearchTestUtils.useMochitestEngines(searchExtensions); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ["browser.urlbar.eventTelemetry.enabled", true], + // Bug 1775917: Disable the persisted-search-terms search tip because if + // not dismissed, it can cause issues with other search tests. + ["browser.urlbar.tipShownCount.searchTip_persist", 999], + ], + }); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT); + + registerCleanupFunction(async () => { + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + }); +}); + +add_task(async function test_trending_results() { + await check_results({ featureEnabled: true }); + await check_results({ featureEnabled: false }); +}); + +async function check_results({ featureEnabled = false }) { + Services.telemetry.clearEvents(); + Services.telemetry.clearScalars(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.richSuggestions.featureGate", featureEnabled]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + waitForFocus: SimpleTest.waitForFocus, + }); + + let numResults = UrlbarTestUtils.getResultCount(window); + + for (let i = 0; i < numResults; i++) { + let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.providerName, "SearchSuggestions"); + Assert.equal(result.payload.engine, "basic"); + Assert.equal(result.payload.isRichSuggestion, featureEnabled); + if (featureEnabled) { + Assert.equal(typeof result.payload.description, "string"); + Assert.ok(result.payload.icon.startsWith("data:")); + } + } + + info("Select first remote search suggestion & hit Enter."); + EventUtils.synthesizeKey("KEY_ArrowDown", {}, window); + EventUtils.synthesizeKey("VK_RETURN", {}, window); + + let event = { + category: "urlbar", + method: "engagement", + object: "enter", + value: "typed", + extra: { + elapsed: val => parseInt(val) > 0, + numChars: "0", + numWords: "0", + selIndex: "0", + selType: featureEnabled ? "trending_rich" : "trending", + provider: "SearchSuggestions", + }, + }; + + TelemetryTestUtils.assertEvents([event], { + category: "urlbar", + }); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, "urlbar.engagement", 1); + + await SpecialPowers.popPrefEnv(); +} diff --git a/browser/components/search/test/browser/browser_searchEngine_behaviors.js b/browser/components/search/test/browser/browser_searchEngine_behaviors.js new file mode 100644 index 0000000000..4303249e63 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchEngine_behaviors.js @@ -0,0 +1,223 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Test search plugin URLs + */ + +"use strict"; + +const SEARCH_ENGINE_DETAILS = [ + { + alias: "a", + baseURL: + "https://www.amazon.com/exec/obidos/external-search/?field-keywords=foo&ie=UTF-8&mode=blended&tag=moz-us-20&sourceid=Mozilla-search", + codes: { + context: "", + keyword: "", + newTab: "", + submission: "", + }, + name: "Amazon.com", + }, + { + alias: "b", + baseURL: `https://www.bing.com/search?{code}pc=${ + SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "MOZR" : "MOZI" + }&q=foo`, + codes: { + context: "form=MOZCON&", + keyword: "form=MOZLBR&", + newTab: "form=MOZTSB&", + submission: "form=MOZSBR&", + }, + name: "Bing", + }, + { + alias: "d", + baseURL: `https://duckduckgo.com/?{code}t=${ + SearchUtils.MODIFIED_APP_CHANNEL == "esr" ? "ftsa" : "ffab" + }&q=foo`, + codes: { + context: "", + keyword: "", + newTab: "", + submission: "", + }, + name: "DuckDuckGo", + }, + { + alias: "e", + baseURL: + "https://www.ebay.com/sch/?toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=711-53200-19255-0&kw=foo", + codes: { + context: "", + keyword: "", + newTab: "", + submission: "", + }, + name: "eBay", + }, + // { + // TODO: Google is tested in browser_google_behaviors.js - we can't test it here + // yet because of bug 1315953. + // alias: "g", + // baseURL: "https://www.google.com/search?q=foo&ie=utf-8&oe=utf-8", + // codes: { + // context: "", + // keyword: "", + // newTab: "", + // submission: "", + // }, + // name: "Google", + // }, +]; + +function promiseContentSearchReady(browser) { + return SpecialPowers.spawn(browser, [], async function (args) { + SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + await ContentTaskUtils.waitForCondition( + () => + content.wrappedJSObject.gContentSearchController && + content.wrappedJSObject.gContentSearchController.defaultEngine + ); + }); +} + +add_task(async function test_setup() { + await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); +}); + +for (let engine of SEARCH_ENGINE_DETAILS) { + add_task(async function () { + let previouslySelectedEngine = await Services.search.getDefault(); + + registerCleanupFunction(async function () { + await Services.search.setDefault( + previouslySelectedEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); + + await testSearchEngine(engine); + }); +} + +async function testSearchEngine(engineDetails) { + let engine = Services.search.getEngineByName(engineDetails.name); + Assert.ok(engine, `${engineDetails.name} is installed`); + + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + engine.alias = engineDetails.alias; + + let base = engineDetails.baseURL; + + // Test search URLs (including purposes). + let url = engine.getSubmission("foo").uri.spec; + Assert.equal( + url, + base.replace("{code}", engineDetails.codes.submission), + "Check search URL for 'foo'" + ); + let sb = BrowserSearch.searchBar; + + let engineTests = [ + { + name: "context menu search", + searchURL: base.replace("{code}", engineDetails.codes.context), + run() { + // Simulate a contextmenu search + // FIXME: This is a bit "low-level"... + BrowserSearch._loadSearch( + "foo", + false, + false, + "contextmenu", + Services.scriptSecurityManager.getSystemPrincipal() + ); + }, + }, + { + name: "keyword search", + searchURL: base.replace("{code}", engineDetails.codes.keyword), + run() { + gURLBar.value = "? foo"; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + }, + }, + { + name: "keyword search with alias", + searchURL: base.replace("{code}", engineDetails.codes.keyword), + run() { + gURLBar.value = `${engineDetails.alias} foo`; + gURLBar.focus(); + EventUtils.synthesizeKey("KEY_Enter"); + }, + }, + { + name: "search bar search", + searchURL: base.replace("{code}", engineDetails.codes.submission), + run() { + sb.focus(); + sb.value = "foo"; + EventUtils.synthesizeKey("KEY_Enter"); + }, + }, + { + name: "new tab search", + searchURL: base.replace("{code}", engineDetails.codes.newTab), + async preTest(tab) { + let browser = tab.linkedBrowser; + BrowserTestUtils.loadURIString(browser, "about:newtab"); + + await BrowserTestUtils.browserLoaded(browser, false, "about:newtab"); + await promiseContentSearchReady(browser); + }, + async run(tab) { + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + let input = content.document.querySelector("input[id*=search-]"); + input.focus(); + input.value = "foo"; + }); + EventUtils.synthesizeKey("KEY_Enter"); + }, + }, + ]; + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + for (let test of engineTests) { + info(`Running: ${test.name}`); + + if (test.preTest) { + await test.preTest(tab); + } + + let promises = [ + BrowserTestUtils.waitForDocLoadAndStopIt(test.searchURL, tab), + BrowserTestUtils.browserStopped(tab.linkedBrowser, test.searchURL, true), + ]; + + await test.run(tab); + + await Promise.all(promises); + } + + engine.alias = undefined; + sb.value = ""; + BrowserTestUtils.removeTab(tab); +} diff --git a/browser/components/search/test/browser/browser_search_annotation.js b/browser/components/search/test/browser/browser_search_annotation.js new file mode 100644 index 0000000000..991646657e --- /dev/null +++ b/browser/components/search/test/browser/browser_search_annotation.js @@ -0,0 +1,176 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test whether a visit information is annotated correctly when searching on searchbar. + +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", +}); + +const FRECENCY = { + SEARCHED: 100, + BOOKMARKED: 175, +}; + +const { VISIT_SOURCE_BOOKMARKED, VISIT_SOURCE_SEARCHED } = PlacesUtils.history; + +async function assertDatabase({ targetURL, expected }) { + const frecency = await PlacesTestUtils.getDatabaseValue( + "moz_places", + "frecency", + { url: targetURL } + ); + Assert.equal(frecency, expected.frecency, "Frecency is correct"); + + const placesId = await PlacesTestUtils.getDatabaseValue("moz_places", "id", { + url: targetURL, + }); + const db = await PlacesUtils.promiseDBConnection(); + const rows = await db.execute( + "SELECT source, triggeringPlaceId FROM moz_historyvisits WHERE place_id = :place_id AND source = :source", + { + place_id: placesId, + source: expected.source, + } + ); + Assert.equal(rows.length, 1); + Assert.equal( + rows[0].getResultByName("triggeringPlaceId"), + null, + `The triggeringPlaceId in database is correct for ${targetURL}` + ); +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await gCUITestUtils.addSearchBar(); + await SearchTestUtils.installSearchExtension( + { + name: "Example", + keyword: "@test", + }, + { setAsDefault: true } + ); + + registerCleanupFunction(async function () { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function basic() { + const testData = [ + { + description: "Normal search", + input: "abc", + resultURL: "https://example.com/?q=abc", + expected: { + source: VISIT_SOURCE_SEARCHED, + frecency: FRECENCY.SEARCHED, + }, + }, + { + description: "Search but the url is bookmarked", + input: "abc", + resultURL: "https://example.com/?q=abc", + bookmarks: [ + { + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: Services.io.newURI("https://example.com/?q=abc"), + title: "test bookmark", + }, + ], + expected: { + source: VISIT_SOURCE_BOOKMARKED, + frecency: FRECENCY.BOOKMARKED, + }, + }, + ]; + + for (const { + description, + input, + resultURL, + bookmarks, + expected, + } of testData) { + info(description); + + for (const bookmark of bookmarks || []) { + await PlacesUtils.bookmarks.insert(bookmark); + } + + const onLoad = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + false, + resultURL + ); + await searchInSearchbar(input); + let promiseVisited = PlacesTestUtils.waitForNotification( + "page-visited", + events => events.some(e => e.url == resultURL) + ); + EventUtils.synthesizeKey("KEY_Enter"); + await onLoad; + await promiseVisited; + await assertDatabase({ targetURL: resultURL, expected }); + + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + } +}); + +add_task(async function contextmenu() { + await BrowserTestUtils.withNewTab( + "https://example.com/browser/browser/components/search/test/browser/test_search.html", + async () => { + // Select html content. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + await new Promise(resolve => { + content.document.addEventListener("selectionchange", resolve, { + once: true, + }); + content.document + .getSelection() + .selectAllChildren(content.document.body); + }); + }); + + const onPopup = BrowserTestUtils.waitForEvent(document, "popupshown"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#id", + { type: "contextmenu" }, + gBrowser.selectedBrowser + ); + await onPopup; + + const targetURL = "https://example.com/?q=test%2520search"; + const onLoad = BrowserTestUtils.waitForNewTab(gBrowser, targetURL, true); + const contextMenu = document.getElementById("contentAreaContextMenu"); + const openLinkMenuItem = contextMenu.querySelector( + "#context-searchselect" + ); + let promiseVisited = PlacesTestUtils.waitForNotification( + "page-visited", + events => events.some(e => e.url == targetURL) + ); + contextMenu.activateItem(openLinkMenuItem); + const tab = await onLoad; + await promiseVisited; + await assertDatabase({ + targetURL, + expected: { + source: VISIT_SOURCE_SEARCHED, + frecency: FRECENCY.SEARCHED, + }, + }); + + BrowserTestUtils.removeTab(tab); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + } + ); +}); diff --git a/browser/components/search/test/browser/browser_search_discovery.js b/browser/components/search/test/browser/browser_search_discovery.js new file mode 100644 index 0000000000..94c198776c --- /dev/null +++ b/browser/components/search/test/browser/browser_search_discovery.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +// Bug 1588193 - BrowserTestUtils.waitForContentEvent now resolves slightly +// earlier than before, so it no longer suffices to only wait for a single event +// tick before checking if browser.engines has been updated. Instead we use a 1s +// timeout, which may cause the test to take more time. +requestLongerTimeout(2); + +add_task(async function () { + let url = + "http://mochi.test:8888/browser/browser/components/search/test/browser/discovery.html"; + info("Test search discovery"); + await BrowserTestUtils.withNewTab(url, searchDiscovery); +}); + +let searchDiscoveryTests = [ + { text: "rel search discovered" }, + { rel: "SEARCH", text: "rel is case insensitive" }, + { rel: "-search-", pass: false, text: "rel -search- not discovered" }, + { + rel: "foo bar baz search quux", + text: "rel may contain additional rels separated by spaces", + }, + { href: "https://not.mozilla.com", text: "HTTPS ok" }, + { href: "data:text/foo,foo", pass: false, text: "data URI not permitted" }, + { href: "javascript:alert(0)", pass: false, text: "JS URI not permitted" }, + { + type: "APPLICATION/OPENSEARCHDESCRIPTION+XML", + text: "type is case insensitve", + }, + { + type: " application/opensearchdescription+xml ", + text: "type may contain extra whitespace", + }, + { + type: "application/opensearchdescription+xml; charset=utf-8", + text: "type may have optional parameters (RFC2046)", + }, + { + type: "aapplication/opensearchdescription+xml", + pass: false, + text: "type should not be loosely matched", + }, + { + rel: "search search search", + count: 1, + text: "only one engine should be added", + }, +]; + +async function searchDiscovery() { + let browser = gBrowser.selectedBrowser; + + for (let testCase of searchDiscoveryTests) { + if (testCase.pass == undefined) { + testCase.pass = true; + } + testCase.title = testCase.title || searchDiscoveryTests.indexOf(testCase); + + let promiseLinkAdded = BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "DOMLinkAdded", + false, + null, + true + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [testCase], test => { + let doc = content.document; + let head = doc.getElementById("linkparent"); + let link = doc.createElement("link"); + link.rel = test.rel || "search"; + link.href = test.href || "http://so.not.here.mozilla.com/search.xml"; + link.type = test.type || "application/opensearchdescription+xml"; + link.title = test.title; + head.appendChild(link); + }); + + await promiseLinkAdded; + await new Promise(resolve => setTimeout(resolve, 1000)); + + if (browser.engines) { + info(`Found ${browser.engines.length} engines`); + info(`First engine title: ${browser.engines[0].title}`); + let hasEngine = testCase.count + ? browser.engines[0].title == testCase.title && + browser.engines.length == testCase.count + : browser.engines[0].title == testCase.title; + ok(hasEngine, testCase.text); + browser.engines = null; + } else { + ok(!testCase.pass, testCase.text); + } + } + + info("Test multiple engines with the same title"); + let promiseLinkAdded = BrowserTestUtils.waitForContentEvent( + gBrowser.selectedBrowser, + "DOMLinkAdded", + false, + e => e.target.href == "http://second.mozilla.com/search.xml", + true + ); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let doc = content.document; + let head = doc.getElementById("linkparent"); + let link = doc.createElement("link"); + link.rel = "search"; + link.href = "http://first.mozilla.com/search.xml"; + link.type = "application/opensearchdescription+xml"; + link.title = "Test Engine"; + let link2 = link.cloneNode(false); + link2.href = "http://second.mozilla.com/search.xml"; + head.appendChild(link); + head.appendChild(link2); + }); + + await promiseLinkAdded; + await new Promise(resolve => setTimeout(resolve, 1000)); + + ok(browser.engines, "has engines"); + is(browser.engines.length, 1, "only one engine"); + is( + browser.engines[0].uri, + "http://first.mozilla.com/search.xml", + "first engine wins" + ); + browser.engines = null; +} diff --git a/browser/components/search/test/browser/browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js new file mode 100644 index 0000000000..99be6ca76b --- /dev/null +++ b/browser/components/search/test/browser/browser_search_glean_serp_telemetry_enabled_by_nimbus_variable.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test to verify we can toggle the Glean SERP telemetry feature via a Nimbus +// variable. + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serpEventsEnabled", + "browser.search.serpEventTelemetry.enabled", + false +); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +async function verifyEventsRecorded() { + function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; + } + + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + await waitForPageWithAdImpressions(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ]); + + BrowserTestUtils.removeTab(tab); + + assertAbandonmentEvent({ + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }); +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.log", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + await SpecialPowers.popPrefEnv(); + resetTelemetry(); + }); +}); + +add_task(async function test_enable_experiment() { + Assert.equal( + lazy.serpEventsEnabled, + false, + "serpEventsEnabled should be false when not enrolled in experiment." + ); + + await lazy.ExperimentAPI.ready(); + + let doExperimentCleanup = await lazy.ExperimentFakes.enrollWithFeatureConfig( + { + featureId: NimbusFeatures.search.featureId, + value: { + serpEventTelemetryEnabled: true, + }, + }, + { isRollout: true } + ); + + Assert.equal( + lazy.serpEventsEnabled, + true, + "serpEventsEnabled should be true when enrolled in experiment." + ); + + // To ensure Nimbus set "browser.search.serpEventTelemetry.enabled" to true, + // we test that an impression, ad_impression and abandonment event are + // recorded correctly. + await verifyEventsRecorded(); + + await doExperimentCleanup(); + + Assert.equal( + lazy.serpEventsEnabled, + false, + "serpEventsEnabled should be false after experiment." + ); +}); diff --git a/browser/components/search/test/browser/browser_search_nimbus_reload.js b/browser/components/search/test/browser/browser_search_nimbus_reload.js new file mode 100644 index 0000000000..19247c9a02 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_nimbus_reload.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExperimentFakes } = ChromeUtils.importESModule( + "resource://testing-common/NimbusTestUtils.sys.mjs" +); + +const { SearchService } = ChromeUtils.importESModule( + "resource://gre/modules/SearchService.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +add_task(async function test_engines_reloaded_nimbus() { + let reloadSpy = sinon.spy(SearchService.prototype, "_maybeReloadEngines"); + let getVariableSpy = sinon.spy( + NimbusFeatures.searchConfiguration, + "getVariable" + ); + + let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({ + featureId: "searchConfiguration", + value: { experiment: "nimbus-search-mochitest" }, + }); + + Assert.equal(reloadSpy.callCount, 1, "Called by experiment enrollment"); + await BrowserTestUtils.waitForCondition( + () => getVariableSpy.calledWith("experiment"), + "Wait for SearchService update to run" + ); + Assert.equal( + getVariableSpy.callCount, + 3, + "Called by update function to fetch engines" + ); + Assert.ok( + getVariableSpy.calledWith("experiment"), + "Called by search service observer" + ); + Assert.equal( + NimbusFeatures.searchConfiguration.getVariable("experiment"), + "nimbus-search-mochitest", + "Should have expected value" + ); + + await doExperimentCleanup(); + + Assert.equal(reloadSpy.callCount, 2, "Called by experiment unenrollment"); + + reloadSpy.restore(); + getVariableSpy.restore(); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_abandonment.js b/browser/components/search/test/browser/browser_search_telemetry_abandonment.js new file mode 100644 index 0000000000..f599ad79f9 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_abandonment.js @@ -0,0 +1,243 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests for the Glean SERP abandonment event + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_tab_close() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetry.html") + ); + + BrowserTestUtils.removeTab(tab); + + assertAbandonmentEvent({ + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }); +}); + +add_task(async function test_window_close() { + resetTelemetry(); + + let serpUrl = getSERPUrl("searchTelemetry.html"); + let otherWindow = await BrowserTestUtils.openNewBrowserWindow(); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + otherWindow.gBrowser, + false, + serpUrl + ); + BrowserTestUtils.loadURIString(otherWindow.gBrowser, serpUrl); + await browserLoadedPromise; + + await BrowserTestUtils.closeWindow(otherWindow); + + assertAbandonmentEvent({ + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.WINDOW_CLOSE, + }, + }); +}); + +add_task(async function test_navigation_via_urlbar() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetry.html") + ); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser, + false, + "https://www.example.com/" + ); + BrowserTestUtils.loadURIString(gBrowser, "https://www.example.com"); + await browserLoadedPromise; + + assertAbandonmentEvent({ + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_navigation_via_back_button() { + resetTelemetry(); + + let exampleUrl = "https://example.com/"; + let serpUrl = getSERPUrl("searchTelemetry.html"); + await BrowserTestUtils.withNewTab(exampleUrl, async browser => { + info("example.com is now loaded."); + + let pageLoadPromise = BrowserTestUtils.browserLoaded( + browser, + false, + serpUrl + ); + BrowserTestUtils.loadURIString(browser, serpUrl); + await pageLoadPromise; + info("Serp is now loaded."); + + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow" + ); + browser.goBack(); + await pageShowPromise; + + info("Previous page (example.com) is now loaded after back navigation."); + }); + + assertAbandonmentEvent({ + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }); +}); + +add_task(async function test_click_ad() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ); + + await TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a", + {}, + gBrowser.selectedBrowser + ); + await browserLoadedPromise; + + Assert.equal( + !!Glean.serp.abandonment.testGetValue(), + false, + "Should not have any abandonment events." + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_without_components() { + // Mock a provider that doesn't have components. + let providerInfo = [ + { + ...TEST_PROVIDER_INFO[0], + components: [], + }, + ]; + SearchSERPTelemetry.overrideSearchTelemetryForTests(providerInfo); + await waitForIdle(); + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ); + + // We shouldn't expect a SERP impression, so instead wait roughly + // around how long it would usually take to receive an impression following + // a page load. + await promiseWaitForAdLinkCheck(); + Assert.equal( + !!Glean.serp.impression.testGetValue(), + false, + "Should not have any impression events." + ); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser, + false, + "https://www.example.com/" + ); + BrowserTestUtils.loadURIString(gBrowser, "https://www.example.com"); + await browserLoadedPromise; + + Assert.equal( + !!Glean.serp.abandonment.testGetValue(), + false, + "Should not have any abandonment events." + ); + + BrowserTestUtils.removeTab(tab); + + // Allow subsequent tests to use the default provider. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_aboutHome.js b/browser/components/search/test/browser/browser_search_telemetry_aboutHome.js new file mode 100644 index 0000000000..702cbabb0f --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_aboutHome.js @@ -0,0 +1,135 @@ +"use strict"; + +const SCALAR_ABOUT_HOME = "browser.engagement.navigation.about_home"; + +add_setup(async function () { + // about:home uses IndexedDB. However, the test finishes too quickly and doesn't + // allow it enougth time to save. So it throws. This disables all the uncaught + // exception in this file and that's the reason why we split about:home tests + // out of the other UsageTelemetry files. + ignoreAllUncaughtExceptions(); + + // Create two new search engines. Mark one as the default engine, so + // the test don't crash. We need to engines for this test as the searchbar + // in content doesn't display the default search engine among the one-off engines. + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + }, + { setAsDefault: true } + ); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch2", + keyword: "mozalias2", + }); + + // Move the second engine at the beginning of the one-off list. + let engineOneOff = Services.search.getEngineByName("MozSearch2"); + await Services.search.moveEngine(engineOneOff, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + Services.telemetry.canRecordExtended = oldCanRecord; + }); +}); + +add_task(async function test_abouthome_activitystream_simpleQuery() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + Services.fog.testResetFOG(); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Load about:home."); + BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:home"); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, "about:home"); + + info("Wait for ContentSearchUI search provider to initialize."); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition( + () => content.wrappedJSObject.gContentSearchController.defaultEngine + ); + }); + + info("Trigger a simple search, just test + enter."); + let p = BrowserTestUtils.browserStopped( + tab.linkedBrowser, + "https://example.com/?q=test+query" + ); + await typeInSearchField( + tab.linkedBrowser, + "test query", + "newtab-search-text" + ); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_ABOUT_HOME, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_ABOUT_HOME]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.abouthome", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "about_home", + value: "enter", + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Also also check Glean events. + const record = Glean.newtabSearch.issued.testGetValue(); + Assert.ok(!!record, "Must have recorded a search issuance"); + Assert.equal(record.length, 1, "One search, one event"); + Assert.deepEqual( + { + search_access_point: "about_home", + telemetry_id: "other-MozSearch", + }, + record[0].extra, + "Must have recorded the expected information." + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_adImpression_component.js b/browser/components/search/test/browser/browser_search_telemetry_adImpression_component.js new file mode 100644 index 0000000000..c9a4c6a8be --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_adImpression_component.js @@ -0,0 +1,401 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const WINDOW_HEIGHT = 768; +const WINDOW_WIDTH = 1024; + +// Note: example.org is used for the SERP page, and example.com is used to serve +// the ads. This is done to simulate different domains like the real servers. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + included: { + parent: { + selector: ".moz-carousel", + }, + children: [ + { + selector: ".moz-carousel-card", + countChildren: true, + }, + ], + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + included: { + parent: { + selector: ".refined-search-buttons", + }, + children: [ + { + selector: "a", + }, + ], + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + included: { + parent: { + selector: ".moz_ad", + }, + children: [ + { + selector: ".multi-col", + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + }, + excluded: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SIDEBAR, + included: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +async function promiseAdImpressionReceived() { + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); +} + +async function promiseResize(width, height) { + return TestUtils.waitForCondition(() => { + return window.outerWidth === width && window.outerHeight === height; + }, "Waiting for window to resize"); +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + // The tests evaluate whether or not ads are visible depending on whether + // they are within the view of the window. To ensure the test results + // are consistent regardless of where they are launched, + // set the window size to something reasonable. + let originalWidth = window.outerWidth; + let originalHeight = window.outerHeight; + window.resizeTo(WINDOW_WIDTH, WINDOW_HEIGHT); + await promiseResize(WINDOW_WIDTH, WINDOW_HEIGHT); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + window.resizeTo(originalWidth, originalHeight); + await promiseResize(originalWidth, originalHeight); + resetTelemetry(); + }); +}); + +add_task(async function test_ad_impressions_with_one_carousel() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "3", + ads_hidden: "0", + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// This is to ensure we're not counting two carousel components as two +// separate components but as one record with a sum of the results. +add_task(async function test_ad_impressions_with_two_carousels() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel_doubled.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + // This is to ensure we've seen the other carousel regardless the + // size of the browser window. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let el = content.document + .getElementById("second-ad") + .getBoundingClientRect(); + // The 100 is just to guarantee we've scrolled past the element. + content.scrollTo(0, el.top + el.height + 100); + }); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "8", + ads_visible: "6", + ads_hidden: "0", + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task( + async function test_ad_impressions_with_carousels_with_outer_container() { + resetTelemetry(); + let url = getSERPUrl( + "searchTelemetryAd_components_carousel_outer_container.html" + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "3", + ads_hidden: "0", + }, + ]); + + BrowserTestUtils.removeTab(tab); + } +); + +add_task(async function test_ad_impressions_with_carousels_tabhistory() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + // Reset telemetry because we care about the telemetry upon going back. + resetTelemetry(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "https://www.example.com/some_url" + ); + await browserLoadedPromise; + + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goBack(); + await pageShowPromise; + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "3", + ads_hidden: "0", + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_hidden_carousels() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel_hidden.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "0", + ads_hidden: "4", + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_carousel_scrolled_left() { + resetTelemetry(); + let url = getSERPUrl( + "searchTelemetryAd_components_carousel_first_element_non_visible.html" + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "2", + ads_hidden: "0", + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_carousel_below_the_fold() { + resetTelemetry(); + let url = getSERPUrl( + "searchTelemetryAd_components_carousel_below_the_fold.html" + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "0", + ads_hidden: "0", + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_text_links() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SIDEBAR, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ]); + BrowserTestUtils.removeTab(tab); +}); + +// An ad is considered visible if at least one link is within the viewable +// content area when the impression was taken. Since the user can scroll +// the page before ad impression is recorded, we should ensure that an +// ad that was scrolled onto the screen before the impression is taken is +// properly recorded. Additionally, some ads might have a large content +// area that extends beyond the viewable area, but as long as a single +// ad link was viewable within the area, we should count the ads as visible. +add_task(async function test_ad_visibility() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_visibility.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let el = content.document + .getElementById("second-ad") + .getBoundingClientRect(); + // The 100 is just to guarantee we've scrolled past the element. + content.scrollTo(0, el.top + el.height + 100); + }); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "6", + ads_visible: "4", + ads_hidden: "0", + }, + ]); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_impressions_without_ads() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await promiseAdImpressionReceived(); + + assertAdImpressionEvents([ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ]); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_categorization_timing.js b/browser/components/search/test/browser/browser_search_telemetry_categorization_timing.js new file mode 100644 index 0000000000..69b43ae19a --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_categorization_timing.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Checks that telemetry on the runtime performance of categorizing the SERP + * works as normal. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_tab_contains_measurement() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_components_text.html") + ); + await waitForPageWithAdImpressions(); + + await Services.fog.testFlushAllChildren(); + Assert.ok( + Glean.serp.adImpression.testGetValue().length, + "Should have received ad impressions." + ); + + let durations = Glean.serp.categorizationDuration.testGetValue(); + Assert.ok(durations.sum > 0, "Sum should be more than 0."); + + BrowserTestUtils.removeTab(tab); +}); + +// If the user opened a SERP and closed it quickly or navigated away from it +// and no ad impressions were recorded, we shouldn't record a measurement. +add_task(async function test_before_ad_impressions_recorded() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_components_text.html") + ); + BrowserTestUtils.removeTab(tab); + + Assert.ok( + !Glean.serp.adImpression.testGetValue(), + "Should not have an ad impression." + ); + + await Services.fog.testFlushAllChildren(); + let durations = Glean.serp.categorizationDuration.testGetValue(); + Assert.equal(durations, undefined, "Should not have received any values."); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_content.js b/browser/components/search/test/browser/browser_search_telemetry_content.js new file mode 100644 index 0000000000..b17604badd --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_content.js @@ -0,0 +1,204 @@ +"use strict"; + +const BASE_PROBE_NAME = "browser.engagement.navigation."; +const SCALAR_CONTEXT_MENU = BASE_PROBE_NAME + "contextmenu"; +const SCALAR_ABOUT_NEWTAB = BASE_PROBE_NAME + "about_newtab"; + +add_setup(async function () { + // Create two new search engines. Mark one as the default engine, so + // the test don't crash. We need to engines for this test as the searchbar + // in content doesn't display the default search engine among the one-off engines. + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + }, + { setAsDefault: true } + ); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch2", + keyword: "mozalias2", + }); + + // Move the second engine at the beginning of the one-off list. + let engineOneOff = Services.search.getEngineByName("MozSearch2"); + await Services.search.moveEngine(engineOneOff, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + Services.telemetry.canRecordExtended = oldCanRecord; + }); +}); + +add_task(async function test_context_menu() { + // Let's reset the Telemetry data. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + // Open a new tab with a page containing some text. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/plain;charset=utf8,test%20search" + ); + + info("Select all the text in the page."); + await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () { + return new Promise(resolve => { + content.document.addEventListener("selectionchange", () => resolve(), { + once: true, + }); + content.document.getSelection().selectAllChildren(content.document.body); + }); + }); + + info("Open the context menu."); + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupPromise; + + info("Click on search."); + let searchItem = contextMenu.getElementsByAttribute( + "id", + "context-searchselect" + )[0]; + contextMenu.activateItem(searchItem); + + info("Validate the search metrics."); + + // Telemetry is not updated synchronously here, we must wait for it. + await TestUtils.waitForCondition(() => { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + return Object.keys(scalars[SCALAR_CONTEXT_MENU] || {}).length == 1; + }, "This search must increment one entry in the scalar."); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_CONTEXT_MENU, + "search", + 1 + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.contextmenu", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "contextmenu", + value: null, + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + contextMenu.hidePopup(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_about_newtab() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + Services.fog.testResetFOG(); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab", + false + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => !content.document.hidden); + }); + + info("Trigger a simple serch, just text + enter."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await typeInSearchField( + tab.linkedBrowser, + "test query", + "newtab-search-text" + ); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_ABOUT_NEWTAB, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_ABOUT_NEWTAB]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.newtab", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "about_newtab", + value: "enter", + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Also also check Glean events. + const record = Glean.newtabSearch.issued.testGetValue(); + Assert.ok(!!record, "Must have recorded a search issuance"); + Assert.equal(record.length, 1, "One search, one event"); + Assert.deepEqual( + { + search_access_point: "about_newtab", + telemetry_id: "other-MozSearch", + }, + record[0].extra, + "Must have recorded the expected information." + ); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_cached.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached.js new file mode 100644 index 0000000000..6def0ef3ad --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on cacheable links. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + included: { + parent: { + selector: ".moz-carousel", + }, + children: [ + { + selector: ".moz-carousel-card", + countChildren: true, + }, + ], + related: { + selector: "button", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + included: { + parent: { + selector: ".moz_ad", + }, + children: [ + { + selector: ".multi-col", + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + related: { + selector: "button", + }, + }, + excluded: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +async function promiseImpressionReceived() { + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); +} + +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_click_cached_page() { + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let cacheableUrl = + "https://example.com/browser/browser/components/search/test/browser/cacheable.html"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + cacheableUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#non_ads_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + gBrowser.goBack(); + await waitForPageWithAdImpressions(); + + pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + cacheableUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#non_ads_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_cached_serp.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached_serp.js new file mode 100644 index 0000000000..26ebea5a56 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_cached_serp.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests check when a SERP retrieves data from the BFCache as SERPs + * typically set their response headers with Cache-Control as private. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [/^https:\/\/example.com/], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, term) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=${term}`; +} + +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +async function goBack(tab, callback = async () => {}) { + info("Go back."); + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goBack(); + await pageShowPromise; + await callback(); +} + +async function goForward(tab, callback = async () => {}) { + info("Go forward."); + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goForward(); + await pageShowPromise; + await callback(); +} + +// This test loads a cached SERP and checks returning to it and interacting +// with elements on the page don't count the events more than once. +// This is a proxy for ensuring we remove event listeners. +add_task(async function test_cached_serp() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + info("Load search page."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + for (let index = 0; index < 3; ++index) { + info("Load non-search page."); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, true); + BrowserTestUtils.loadURIString( + tab.linkedBrowser, + "https://www.example.com" + ); + await loadPromise; + await goBack(tab, async () => { + await waitForPageWithAdImpressions(); + }); + } + + info("Click on searchbox."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "input", + {}, + tab.linkedBrowser + ); + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + Assert.equal( + engagements.length, + 1, + "There should be 1 engagement event recorded." + ); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_back_and_forward_serp_to_serp() { + await SpecialPowers.pushPrefEnv({ + // This has to be disabled or else using back and forward in the test won't + // trigger responses in the network listener in SearchSERPTelemetry. The + // page will still load from a BFCache. + set: [["fission.bfcacheInParent", false]], + }); + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + info("Load search page."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false); + info("Click on a suggested search term."); + BrowserTestUtils.synthesizeMouseAtCenter("#suggest", {}, tab.linkedBrowser); + await loadPromise; + await waitForPageWithAdImpressions(); + + for (let index = 0; index < 3; ++index) { + info("Return to first search page."); + await goBack(tab, async () => { + await waitForPageWithAdImpressions(); + }); + info("Return to second search page."); + await goForward(tab, async () => { + await waitForPageWithAdImpressions(); + }); + } + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + let abandonments = Glean.serp.abandonment.testGetValue() ?? []; + Assert.equal(engagements.length, 1, "There should be 1 engagement."); + Assert.equal(abandonments.length, 6, "There should be 6 abandonments."); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_back_and_forward_content_to_serp_to_serp() { + await SpecialPowers.pushPrefEnv({ + // This has to be disabled or else using back and forward in the test won't + // trigger responses in the network listener in SearchSERPTelemetry. The + // page will still load from a BFCache. + set: [["fission.bfcacheInParent", false]], + }); + resetTelemetry(); + + info("Load non-search page."); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://www.example.com/" + ); + + info("Load search page."); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, true); + BrowserTestUtils.loadURIString(tab.linkedBrowser, url); + await loadPromise; + await waitForPageWithAdImpressions(); + + loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false); + info("Click on a suggested search term."); + BrowserTestUtils.synthesizeMouseAtCenter("#suggest", {}, tab.linkedBrowser); + await loadPromise; + await waitForPageWithAdImpressions(); + + info("Return to first search page."); + await goBack(tab, async () => { + await waitForPageWithAdImpressions(); + }); + + info("Return to non-search page."); + await goBack(tab); + + info("Return to first search page."); + await goForward(tab, async () => { + await waitForPageWithAdImpressions(); + }); + + info("Return to second search page."); + await goForward(tab, async () => { + await waitForPageWithAdImpressions(); + }); + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + let abandonments = Glean.serp.abandonment.testGetValue() ?? []; + Assert.equal(engagements.length, 1, "There should be 1 engagement."); + Assert.equal(abandonments.length, 3, "There should be 3 abandonments."); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_content.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_content.js new file mode 100644 index 0000000000..e42fe4c293 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_content.js @@ -0,0 +1,486 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load a SERP that has multiple ways of refining a search term + * within content, or moving it into another search engine. It is also common + * for providers to remove tracking params. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_searchbox_with_content.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_searchbox_with_content_redirect.html/, + ], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + shoppingTab: { + selector: "nav a", + regexp: "&page=shopping", + inspectRegexpInSERP: true, + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + nonAd: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + included: { + parent: { + selector: ".refined-search-buttons", + }, + children: [ + { + selector: "a", + }, + ], + }, + topDown: true, + nonAd: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +async function promiseImpressionReceived() { + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); +} + +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +// "Tabs" are considered to be links the navigation of a SERP. Their hrefs +// may look similar to a search page, including related searches. +add_task(async function test_click_tab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#images", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Ensure that shopping links on a page with many non-ad link regular +// expressions doesn't get confused for a non-ads link. +add_task(async function test_click_shopping() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#shopping", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "true", + shopping_tab_displayed: "true", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_click_related_search_in_new_tab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-new-tab", + {}, + tab.linkedBrowser + ); + let tab2 = await tabPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// We consider regular expressions in nonAdsLinkRegexps and searchPageRegexp +// as valid non ads links when recording an engagement event. +add_task(async function test_click_redirect_search_in_newtab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-redirect", + {}, + tab.linkedBrowser + ); + let tab2 = await tabPromise; + + await waitForPageWithAdImpressions(); + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// Ensure if a user does a search that uses one of the in-content sources, +// we clear the cached source value. +add_task(async function test_content_source_reset() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Do a text search to trigger a defined target. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "form input", + {}, + tab.linkedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await pageLoadPromise; + + // Click on a related search that will load within the same page and should + // have an unknown target. + await waitForPageWithAdImpressions(); + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-in-page", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression.testGetValue()?.length == 3; + }, "Should have three impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// This test also deliberately includes an anchor with a reserved character in +// the href that gets parsed on page load. This is because when the URL is +// requested and observed in the network process, it is converted into a +// percent encoded string, so we want to ensure we're categorizing the +// component properly. This can happen with refinement buttons. +add_task(async function test_click_refinement_button() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test%27s"; + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_multiple_tabs.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_multiple_tabs.js new file mode 100644 index 0000000000..8010434b88 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_multiple_tabs.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that recorded telemetry is consistent even with multiple + * tabs opened and closed. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_nonAdsLink_redirect/, + ], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + // This isn't contained in any of the HTML examples but the + // presence of the entry ensures that if it is not found during + // a topDown examination, the next element in the array is + // inspected and found. + selector: "textarea", + }, + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +// Deliberately make the web isolated process count as small as possible +// so that we don't have to create a ton of tabs to reuse a process. +const MAX_IPC = 1; +const TABS_TO_OPEN = 2; + +function getSERPUrl(page, term) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=${term}`; +} + +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ["dom.ipc.processCount.webIsolated", MAX_IPC], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +async function do_test(tab, impressionId, switchTab) { + if (switchTab) { + await BrowserTestUtils.switchTab(gBrowser, tab); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + "input", + {}, + tab.linkedBrowser + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#images", + {}, + tab.linkedBrowser + ); + + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, true); + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + Assert.equal(engagements.length, 2, "Should have two events recorded."); + + Assert.deepEqual( + engagements[0].extra, + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + impression_id: impressionId, + }, + "Search box engagement event should match." + ); + Assert.deepEqual( + engagements[1].extra, + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + impression_id: impressionId, + }, + "Non ads page engagement event should match." + ); + resetTelemetry(); +} + +// This test deliberately opens a lot of tabs to ensure SERPs share the +// same process. It interacts with the page to ensure the engagement +// has the correct recording, especially the impression id which can be out of +// sync if data in the child process isn't cached properly. +add_task(async function test_multiple_tabs_forward() { + resetTelemetry(); + + let tabs = []; + let pid; + + // Open multiple tabs. + for (let index = 0; index < TABS_TO_OPEN; ++index) { + let url = getSERPUrl( + "searchTelemetryAd_searchbox_with_content.html", + `hello+world+${index}` + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + tabs.push(tab); + let currentPid = E10SUtils.getBrowserPids(tab.linkedBrowser).at(0); + if (pid == null) { + pid = currentPid; + } else { + Assert.ok(pid == currentPid, "The process ID should be the same."); + } + } + + // Extract the impression IDs. + await Services.fog.testFlushAllChildren(); + let recordedImpressions = Glean.serp.impression.testGetValue() ?? []; + let impressionIds = recordedImpressions.map( + impression => impression.extra.impression_id + ); + + // Reset telemetry because we're not concerned about inspecting every + // impression event. + resetTelemetry(); + + for (let index = 0; index < TABS_TO_OPEN; ++index) { + let tab = tabs[index]; + let impressionId = impressionIds[index]; + await do_test(tab, impressionId, true); + BrowserTestUtils.removeTab(tab); + } +}); + +add_task(async function test_multiple_tabs_backward() { + resetTelemetry(); + + let tabs = []; + let pid; + + for (let index = 0; index < TABS_TO_OPEN; ++index) { + let url = getSERPUrl( + "searchTelemetryAd_searchbox_with_content.html", + `hello+world+${index}` + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + tabs.push(tab); + let currentPid = E10SUtils.getBrowserPids(tab.linkedBrowser).at(0); + if (pid == null) { + pid = currentPid; + } else { + Assert.ok(pid == currentPid, "The process ID should be the same."); + } + } + + // Extract the impression IDs. + await Services.fog.testFlushAllChildren(); + let recordedImpressions = Glean.serp.impression.testGetValue() ?? []; + let impressionIds = recordedImpressions.map( + impression => impression.extra.impression_id + ); + + // Reset telemetry because we're not concerned about inspecting every + // impression event. + resetTelemetry(); + + for (let index = TABS_TO_OPEN - 1; index >= 0; --index) { + let tab = tabs[index]; + let impressionId = impressionIds[index]; + await do_test(tab, impressionId, false); + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_non_ad.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_non_ad.js new file mode 100644 index 0000000000..ebec383e92 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_non_ad.js @@ -0,0 +1,164 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on links that are non ads. Non ads can have + * slightly different behavior from ads. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +async function promiseImpressionReceived() { + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); +} + +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +// If an anchor is a non_ads_link and it doesn't match a non-ads regular +// expression, it should still be categorize it as a non ad. +add_task(async function test_click_non_ads_link() { + await waitForIdle(); + + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click a non ad. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + + // Reset state for other tests. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); + +// Click on an non-ad element while no ads are present. +add_task(async function test_click_non_ad_with_no_ads() { + await waitForIdle(); + + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.com/hello_world" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link", + {}, + tab.linkedBrowser + ); + await browserLoadedPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + + // Reset state for other tests. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_redirect.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_redirect.js new file mode 100644 index 0000000000..30c18b8059 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_redirect.js @@ -0,0 +1,346 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on both ad and non-ad links that can be + * redirected. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_nonAdsLink_redirect.html/, + ], + extraAdServersRegexps: [ + /^https:\/\/example\.com\/ad/, + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/redirect_ad/, + ], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +async function promiseImpressionReceived() { + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); +} + +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_click_non_ads_link_redirected() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_redirected", + {}, + tab.linkedBrowser + ); + + await browserLoadedPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// If a provider does a re-direct and we open it in a new tab, we should +// record the click and have the correct number of engagements. +add_task(async function test_click_non_ads_link_redirected_new_tab() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let redirectUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_nonAdsLink_redirect.html"; + let targetUrl = "https://example.com/hello_world"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + + await SpecialPowers.spawn(tab.linkedBrowser, [redirectUrl], urls => { + content.document + .getElementById(["non_ads_link"]) + .addEventListener("click", e => { + e.preventDefault(); + content.window.open([urls], "_blank"); + }); + content.document.getElementById("non_ads_link").click(); + }); + let tab2 = await tabPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// Some providers load a URL of a non ad within a subframe before loading the +// target website in the top level frame. +add_task(async function test_click_non_ads_link_redirect_non_top_level() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_redirected_no_top_level", + {}, + tab.linkedBrowser + ); + + await browserPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_multiple_redirects_non_ad_link() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_multiple_redirects", + {}, + tab.linkedBrowser + ); + + await browserLoadedPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_click_ad_link_redirected() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad_link_redirect", + {}, + tab.linkedBrowser + ); + + await browserLoadedPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_click_ad_link_redirected_new_tab() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad_link_redirect", + { button: 1 }, + tab.linkedBrowser + ); + let tab2 = await tabPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_engagement_target.js b/browser/components/search/test/browser/browser_search_telemetry_engagement_target.js new file mode 100644 index 0000000000..d29169b776 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_engagement_target.js @@ -0,0 +1,433 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on links that can either be ads or non ads + * and verifies that the engagement events and the target associated with them + * are correct. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_nonAdsLink_redirect.html/, + ], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + included: { + parent: { + selector: ".moz-carousel", + }, + children: [ + { + selector: ".moz-carousel-card", + countChildren: true, + }, + ], + related: { + selector: "button", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + included: { + parent: { + selector: ".moz_ad", + }, + children: [ + { + selector: ".multi-col", + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + related: { + selector: "button", + }, + }, + excluded: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + // This isn't contained in any of the HTML examples but the + // presence of the entry ensures that if it is not found during + // a topDown examination, the next element in the array is + // inspected and found. + selector: "textarea", + }, + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +// This is used to check if not providing an nonAdsLinkRegexp can still +// reliably categorize non_ads_links. +const TEST_PROVIDER_INFO_NO_NON_ADS_REGEXP = [ + { + ...TEST_PROVIDER_INFO[0], + nonAdsLinkRegexps: [], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +async function promiseImpressionReceived() { + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); +} + +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +// This test ensures clicking a non-first link in a component registers the +// proper component. This is because the first link of a component does the +// heavy lifting in finding the parent and best categorization of the +// component. Subsequent anchors that have the same parent get grouped into it. +// Additionally, this test deliberately has ads with different paths so that +// there are no collisions in hrefs. +add_task(async function test_click_second_ad_in_component() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#deep_ad_sitelink", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// If a provider appends query parameters to a link after the page has been +// parsed, we should still be able to record the click. +add_task(async function test_click_ads_link_modified() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + let target = content.document.getElementById("deep_ad_sitelink"); + let href = target.getAttribute("href"); + target.setAttribute("href", href + "?foo=bar"); + content.document.getElementById("deep_ad_sitelink").click(); + }); + await browserLoadedPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Search box is a special case which has to be tracked in the child process. +add_task(async function test_click_and_submit_incontent_searchbox() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click on the searchbox. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser, url); + await BrowserTestUtils.synthesizeMouseAtCenter( + "form input", + {}, + tab.linkedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Click an auto-suggested term. The element that is clicked is related +// to the searchbox but not in search-telemetry-v2 because it can be too +// difficult to determine ahead of time since the elements are generated +// dynamically. So instead it should listen to an element higher in the DOM. +add_task(async function test_click_autosuggest() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click an autosuggested term. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser, url); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#suggest", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Carousel related buttons expand content. +add_task(async function test_click_carousel_expand() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click a button that is expected to expand. + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.querySelector("button").click(); + }); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.EXPANDED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// This test clicks a link that has apostrophes in the both the path and list +// of query parameters, and uses search telemetry with no nonAdsRegexps defined, +// which will force us to cache every non ads link in a map and pass it back to +// the parent. +// If this test fails, it means we're doing the conversion wrong, because when +// we observe the clicked URL in the parent process, it should look exactly the +// same as how it was saved in the hrefToComponent map. +add_task(async function test_click_link_with_special_characters_in_path() { + SearchSERPTelemetry.overrideSearchTelemetryForTests( + TEST_PROVIDER_INFO_NO_NON_ADS_REGEXP + ); + await waitForIdle(); + + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + "https://example.com/path'?hello_world&foo=bar%27s" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_with_special_characters_in_path", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + + // Reset state for other tests. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_searchbar.js b/browser/components/search/test/browser/browser_search_telemetry_searchbar.js new file mode 100644 index 0000000000..15f512452a --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_searchbar.js @@ -0,0 +1,440 @@ +"use strict"; + +const SCALAR_SEARCHBAR = "browser.engagement.navigation.searchbar"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +let suggestionEngine; + +function checkHistogramResults(resultIndexes, expected, histogram) { + for (let [i, val] of Object.entries(resultIndexes.values)) { + if (i == expected) { + Assert.equal( + val, + 1, + `expected counts should match for ${histogram} index ${i}` + ); + } else { + Assert.equal( + !!val, + false, + `unexpected counts should be zero for ${histogram} index ${i}` + ); + } + } +} + +/** + * Click one of the entries in the search suggestion popup. + * + * @param {string} entryName + * The name of the elemet to click on. + * @param {object} [clickOptions] + * The options to use for the click. + */ +function clickSearchbarSuggestion(entryName, clickOptions = {}) { + let richlistbox = BrowserSearch.searchBar.textbox.popup.richlistbox; + let richlistitem = Array.prototype.find.call( + richlistbox.children, + item => item.getAttribute("ac-value") == entryName + ); + + // Make sure the suggestion is visible and simulate the click. + richlistbox.ensureElementIsVisible(richlistitem); + EventUtils.synthesizeMouseAtCenter(richlistitem, clickOptions); +} + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + const url = getRootDirectory(gTestPath) + "telemetrySearchSuggestions.xml"; + suggestionEngine = await SearchTestUtils.promiseNewSearchEngine({ url }); + + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); + + // Create two new search engines. Mark one as the default engine, so + // the test don't crash. We need to engines for this test as the searchbar + // doesn't display the default search engine among the one-off engines. + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + }, + { setAsDefault: true } + ); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch2", + keyword: "mozalias2", + }); + + // Move the second engine at the beginning of the one-off list. + let engineOneOff = Services.search.getEngineByName("MozSearch2"); + await Services.search.moveEngine(engineOneOff, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_plainQuery() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Simulate entering a simple search."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("simple query"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHBAR, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.searchbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "searchbar", + value: "enter", + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Check the histograms as well. + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the first result, a one-off button, and the Return +// (Enter) key. +add_task(async function test_oneOff_enter() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Perform a one-off search using the first engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("query"); + + info("Pressing Alt+Down to highlight the first one off engine."); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHBAR, + "search_oneoff", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch2.searchbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "searchbar", + value: "oneoff", + extra: { engine: "other-MozSearch2" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Check the histograms as well. + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the second result, a one-off button, and the Return +// (Enter) key. This only tests the FX_SEARCHBAR_SELECTED_RESULT_METHOD +// histogram since test_oneOff_enter covers everything else. +add_task(async function test_oneOff_enterSelection() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("query"); + + info( + "Select the second result, press Alt+Down to take us to the first one-off engine." + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using a click on a one-off button. This only tests the +// FX_SEARCHBAR_SELECTED_RESULT_METHOD histogram since test_oneOff_enter covers +// everything else. +add_task(async function test_oneOff_click() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let popup = await searchInSearchbar("query"); + info("Click the first one-off button."); + popup.oneOffButtons.getSelectableButtons(false)[0].click(); + await p; + + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + BrowserTestUtils.removeTab(tab); +}); + +async function checkSuggestionClick(clickOptions, waitForActionFn) { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Perform a one-off search using the first engine."); + let p = waitForActionFn(tab); + await searchInSearchbar("query"); + info("Clicking the searchbar suggestion."); + clickSearchbarSuggestion("queryfoo", clickOptions); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHBAR, + "search_suggestion", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + let searchEngineId = "other-" + suggestionEngine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".searchbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "searchbar", + value: "suggestion", + extra: { engine: searchEngineId }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Check the histograms as well. + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserTestUtils.removeTab(tab); +} + +// Clicks the first suggestion offered by the test search engine. +add_task(async function test_suggestion_click() { + await checkSuggestionClick({}, tab => { + return BrowserTestUtils.browserLoaded(tab.linkedBrowser); + }); +}); + +add_task(async function test_suggestion_middle_click() { + let openedTab; + await checkSuggestionClick({ button: 1 }, () => { + return BrowserTestUtils.waitForNewTab(gBrowser, "http://example.com/").then( + tab => (openedTab = tab) + ); + }); + BrowserTestUtils.removeTab(openedTab); +}); + +// Selects and presses the Return (Enter) key on the first suggestion offered by +// the test search engine. This only tests the +// FX_SEARCHBAR_SELECTED_RESULT_METHOD histogram since test_suggestion_click +// covers everything else. +add_task(async function test_suggestion_enterSelection() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("query"); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_shopping.js b/browser/components/search/test/browser/browser_search_telemetry_shopping.js new file mode 100644 index 0000000000..2623562a8f --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_shopping.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check the existence of a shopping tab and navigation to a shopping page. + * Most existing tests don't include shopping tabs, so this explicitly loads a + * page with a shopping tab and clicks on it. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +// The setup for each test is the same, the only differences are the various +// permutations of the search tests. +const BASE_TEST_PROVIDER = { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + extraAdServersRegexps: [/^https:\/\/example\.org\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], +}; + +const TEST_PROVIDER_INFO_1 = [ + { + ...BASE_TEST_PROVIDER, + shoppingTab: { + selector: "nav a", + regexp: "&page=shopping&", + inspectRegexpInSERP: true, + }, + }, +]; + +const TEST_PROVIDER_INFO_2 = [ + { + ...BASE_TEST_PROVIDER, + shoppingTab: { + selector: "nav a#shopping", + regexp: "&page=shopping&", + inspectRegexpInSERP: false, + }, + }, +]; + +function getSERPUrl(page) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test&abc=ff`; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO_1); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +async function loadSerpAndClickShoppingTab(page) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(page) + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + }, + ]); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#shopping", {}, tab.linkedBrowser); + await pageLoadPromise; + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_inspect_shopping_tab_regexp_on_serp() { + resetTelemetry(); + await loadSerpAndClickShoppingTab("searchTelemetryAd_shopping.html"); +}); + +add_task(async function test_no_inspect_shopping_tab_regexp_on_serp() { + resetTelemetry(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO_2); + await waitForIdle(); + await loadSerpAndClickShoppingTab("searchTelemetryAd_shopping.html"); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources.js b/browser/components/search/test/browser/browser_search_telemetry_sources.js new file mode 100644 index 0000000000..173bc09694 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_sources.js @@ -0,0 +1,497 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and link clicking. + * + * NOTE: As this test file is already fairly long-running, adding to this file + * will likely cause timeout errors with test-verify jobs on Treeherder. + * Therefore, please do not add further tasks to this file. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getPageUrl(useAdPage = false) { + let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html"; + return `https://example.org/browser/browser/components/search/test/browser/${page}`; +} + +/** + * Returns the index of the first search suggestion in the urlbar results. + * + * @returns {number} An index, or -1 if there are no search suggestions. + */ +async function getFirstSuggestionIndex() { + const matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + return i; + } + } + return -1; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +SearchTestUtils.init(this); + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + true, + ], + // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.prefs.setBoolPref("browser.search.log", true); + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.org/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + await gCUITestUtils.addSearchBar(); + + registerCleanupFunction(async () => { + gCUITestUtils.removeSearchBar(); + Services.prefs.clearUserPref("browser.search.log"); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +async function track_ad_click( + expectedHistogramSource, + expectedScalarSource, + searchAdsFn, + cleanupFn +) { + searchCounts.clear(); + Services.telemetry.clearScalars(); + + let expectedContentScalarKey = "example:tagged:ff"; + let expectedScalarKey = "example:tagged"; + let expectedHistogramSAPSourceKey = `other-Example.${expectedHistogramSource}`; + let expectedContentScalar = `browser.search.content.${expectedScalarSource}`; + let expectedWithAdsScalar = `browser.search.withads.${expectedScalarSource}`; + let expectedAdClicksScalar = `browser.search.adclicks.${expectedScalarSource}`; + + let tab = await searchAdsFn(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: expectedScalarSource, + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + // Ad impression data is needed to categorize ads on the page in order to + // register ad click events before a click occurs. We don't assert their + // precise values here because other tests cover that the component + // categorizations are valid. + await promiseAdImpressionReceived(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + [expectedAdClicksScalar]: { [expectedScalarKey]: 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: expectedScalarSource, + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + await cleanupFn(); + + Services.fog.testResetFOG(); +} + +add_task(async function test_source_urlbar() { + let tab; + await track_ad_click( + "urlbar", + "urlbar", + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_urlbar_handoff() { + let tab; + await track_ad_click( + "urlbar-handoff", + "urlbar_handoff", + async () => { + Services.fog.testResetFOG(); + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:newtab"); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, "about:newtab"); + + info("Focus on search input in newtab content"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ".fake-editable", + {}, + tab.linkedBrowser + ); + + info("Get suggestions"); + for (const c of "searchSuggestion".split("")) { + EventUtils.synthesizeKey(c); + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(r => setTimeout(r, 50)); + } + await TestUtils.waitForCondition(async () => { + const index = await getFirstSuggestionIndex(); + return index >= 0; + }, "Wait until suggestions are ready"); + + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await onLoaded; + + return tab; + }, + async () => { + const issueRecords = Glean.newtabSearch.issued.testGetValue(); + Assert.ok(!!issueRecords, "Must have recorded a search issuance"); + Assert.equal(issueRecords.length, 1, "One search, one event"); + const newtabVisitId = issueRecords[0].extra.newtab_visit_id; + Assert.ok(!!newtabVisitId, "Must have a visit id"); + Assert.deepEqual( + { + // Yes, this is tautological. But I want to use deepEqual. + newtab_visit_id: newtabVisitId, + search_access_point: "urlbar_handoff", + telemetry_id: "other-Example", + }, + issueRecords[0].extra, + "Must have recorded the expected information." + ); + const impRecords = Glean.newtabSearchAd.impression.testGetValue(); + Assert.equal(impRecords.length, 1, "One impression, one event."); + Assert.deepEqual( + { + newtab_visit_id: newtabVisitId, + search_access_point: "urlbar_handoff", + telemetry_id: "example", + is_tagged: "true", + is_follow_on: "false", + }, + impRecords[0].extra, + "Must have recorded the expected information." + ); + const clickRecords = Glean.newtabSearchAd.click.testGetValue(); + Assert.equal(clickRecords.length, 1, "One click, one event."); + Assert.deepEqual( + { + newtab_visit_id: newtabVisitId, + search_access_point: "urlbar_handoff", + telemetry_id: "example", + is_tagged: "true", + is_follow_on: "false", + }, + clickRecords[0].extra, + "Must have recorded the expected information." + ); + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_searchbar() { + let tab; + await track_ad_click( + "searchbar", + "searchbar", + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let sb = BrowserSearch.searchBar; + // Write the search query in the searchbar. + sb.focus(); + sb.value = "searchSuggestion"; + sb.textbox.controller.startSearch("searchSuggestion"); + // Wait for the popup to show. + await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown"); + // And then for the search to complete. + await BrowserTestUtils.waitForCondition( + () => + sb.textbox.controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH, + "The search in the searchbar must complete." + ); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + } + ); +}); + +async function checkAboutPage( + page, + expectedHistogramSource, + expectedScalarSource +) { + let tab; + await track_ad_click( + expectedHistogramSource, + expectedScalarSource, + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + BrowserTestUtils.loadURIString(tab.linkedBrowser, page); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, page); + + // Wait for the full load. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition( + () => content.wrappedJSObject.gContentSearchController.defaultEngine + ); + }); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await typeInSearchField( + tab.linkedBrowser, + "test query", + "newtab-search-text" + ); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser); + await p; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); + } + ); +} + +add_task(async function test_source_about_home() { + await checkAboutPage("about:home", "abouthome", "about_home"); +}); + +add_task(async function test_source_about_newtab() { + await checkAboutPage("about:newtab", "newtab", "about_newtab"); +}); + +add_task(async function test_source_system() { + let tab; + await track_ad_click( + "system", + "system", + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // This is not quite the same as calling from the commandline, but close + // enough for this test. + BrowserSearch.loadSearchFromCommandLine( + "searchSuggestion", + false, + Services.scriptSecurityManager.getSystemPrincipal(), + gBrowser.selectedBrowser.csp + ); + + await loadPromise; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_webextension_search() { + /* global browser */ + async function background(SEARCH_TERM) { + // Search with no tabId + browser.search.search({ query: "searchSuggestion", engine: "Example" }); + } + + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + useAddonManager: "temporary", + }); + + let tab; + await track_ad_click( + "webextension", + "webextension", + async () => { + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + + await searchExtension.startup(); + + return (tab = await tabPromise); + }, + async () => { + await searchExtension.unload(); + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_webextension_query() { + async function background(SEARCH_TERM) { + // Search with no tabId + browser.search.query({ + text: "searchSuggestion", + disposition: "NEW_TAB", + }); + } + + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + useAddonManager: "temporary", + }); + + let tab; + await track_ad_click( + "webextension", + "webextension", + async () => { + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + + await searchExtension.startup(); + + return (tab = await tabPromise); + }, + async () => { + await searchExtension.unload(); + BrowserTestUtils.removeTab(tab); + } + ); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources_ads.js b/browser/components/search/test/browser/browser_search_telemetry_sources_ads.js new file mode 100644 index 0000000000..880497f993 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_sources_ads.js @@ -0,0 +1,841 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and link clicking. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +// Note: example.org is used for the SERP page, and example.com is used to serve +// the ads. This is done to simulate different domains like the real servers. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, + { + telemetryId: "example-data-attributes", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetryAd_dataAttributes(?:_none|_href)?.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["xyz"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, + { + telemetryId: "slow-page-load", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/slow_loading_page_with_ads(_on_load_event)?.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getPageUrl(useAdPage = false) { + let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html"; + return `https://example.org/browser/browser/components/search/test/browser/${page}`; +} + +function getSERPUrl(page, organic = false) { + return `${page}?s=test${organic ? "" : "&abc=ff"}`; +} + +function getSERPFollowOnUrl(page) { + return page + "?s=test&abc=ff&a=foo"; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.log", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_simple_search_page_visit() { + resetTelemetry(); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: getSERPUrl(getPageUrl()), + }, + async () => { + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + } + ); + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); +}); + +add_task(async function test_simple_search_page_visit_telemetry() { + resetTelemetry(); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + /* URL must not be in the cache */ + url: getSERPUrl(getPageUrl()) + `&random=${Math.random()}`, + }, + async () => { + let scalars = {}; + const key = "browser.search.data_transferred"; + + await TestUtils.waitForCondition(() => { + scalars = + Services.telemetry.getSnapshotForKeyedScalars("main", false).parent || + {}; + return key in scalars; + }, "should have the expected keyed scalars"); + + const scalar = scalars[key]; + Assert.ok("example" in scalar, "correct telemetry category"); + Assert.notEqual(scalars[key].example, 0, "bandwidth logged"); + } + ); + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); +}); + +add_task(async function test_follow_on_visit() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: getSERPFollowOnUrl(getPageUrl()), + }, + async () => { + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example:tagged:ff": 1, + "example:tagged-follow-on:ff": 1, + }, + } + ); + } + ); + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); +}); + +add_task(async function test_track_ad() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(getPageUrl(true)) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_on_data_attributes() { + resetTelemetry(); + + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_dataAttributes.html"; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(url) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example-data-attributes:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example-data-attributes:tagged": 1, + }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example-data-attributes", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_on_data_attributes_and_hrefs() { + resetTelemetry(); + + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_dataAttributes_href.html"; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(url) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example-data-attributes:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example-data-attributes:tagged": 1, + }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example-data-attributes", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_no_ad_on_data_attributes_and_hrefs() { + resetTelemetry(); + + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_dataAttributes_none.html"; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(url) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example-data-attributes:tagged:ff": 1, + }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example-data-attributes", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_on_DOMContentLoaded() { + resetTelemetry(); + + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "slow_loading_page_with_ads.html"; + + let observeAdPreviouslyRecorded = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes( + "Ad was previously reported for browser with URI" + ) + ); + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(url) + ); + + // Observe ad was counted on DOMContentLoaded. + // We do not count the ad again on load. + await observeAdPreviouslyRecorded; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "slow-page-load:tagged:ff": 1 }, + "browser.search.withads.unknown": { "slow-page-load:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "slow-page-load", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_on_load_event() { + resetTelemetry(); + + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "slow_loading_page_with_ads_on_load_event.html"; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(url) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "slow-page-load:tagged:ff": 1 }, + "browser.search.withads.unknown": { "slow-page-load:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "slow-page-load", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_organic() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(getPageUrl(true), true) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:organic:none": 1 }, + "browser.search.withads.unknown": { "example:organic": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_new_window() { + resetTelemetry(); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + let url = getSERPUrl(getPageUrl(true)); + BrowserTestUtils.loadURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + url + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_track_ad_pages_without_ads() { + // Note: the above tests have already checked a page with no ad-urls. + resetTelemetry(); + + let tabs = []; + + tabs.push( + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(getPageUrl(false)) + ) + ); + tabs.push( + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(getPageUrl(true)) + ) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 2 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); + +async function track_ad_click(testOrganic) { + // Note: the above tests have already checked a page with no ad-urls. + resetTelemetry(); + + let expectedScalarKey = `example:${testOrganic ? "organic" : "tagged"}`; + let expectedContentScalarKey = `example:${ + testOrganic ? "organic:none" : "tagged:ff" + }`; + let tagged = testOrganic ? "false" : "true"; + let partnerCode = testOrganic ? "" : "ff"; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(getPageUrl(true), testOrganic) + ); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.unknown": { + [expectedScalarKey.replace("sap", "tagged")]: 1, + }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + await promiseAdImpressionReceived(1); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.unknown": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + // Now go back, and click again. + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + gBrowser.goBack(); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + // We've gone back, so we register an extra display & if it is with ads or not. + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.tabhistory": { [expectedContentScalarKey]: 1 }, + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.tabhistory": { [expectedScalarKey]: 1 }, + "browser.search.withads.unknown": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "tabhistory", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + await promiseAdImpressionReceived(2); + + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.tabhistory": { [expectedContentScalarKey]: 1 }, + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.tabhistory": { [expectedScalarKey]: 1 }, + "browser.search.withads.unknown": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.tabhistory": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "tabhistory", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_track_ad_click() { + await track_ad_click(false); +}); + +add_task(async function test_track_ad_click_organic() { + await track_ad_click(true); +}); + +add_task(async function test_track_ad_click_with_location_change_other_tab() { + resetTelemetry(); + const url = getSERPUrl(getPageUrl(true)); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + await promiseAdImpressionReceived(); + + const newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + + await BrowserTestUtils.switchTab(gBrowser, tab); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + "browser.search.adclicks.unknown": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources_in_content.js b/browser/components/search/test/browser/browser_search_telemetry_sources_in_content.js new file mode 100644 index 0000000000..003b54cc8e --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_sources_in_content.js @@ -0,0 +1,435 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * SearchSERPTelemetry tests related to in-content sources. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + included: { + parent: { + selector: ".refined-search-buttons", + }, + children: [ + { + selector: "a", + }, + ], + }, + topDown: true, + }, + ], + }, +]; + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.prefs.setBoolPref("browser.search.log", true); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.search.log"); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_source_opened_in_new_tab_via_middle_click() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#related-in-page", + { button: 1 }, + tab1.linkedBrowser + ); + let tab2 = await tabPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_source_opened_in_new_tab_via_target_blank() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + // Note: the anchor element with id "related-new-tab" has a target=_blank + // attribute. + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#related-new-tab", + {}, + tab1.linkedBrowser + ); + let tab2 = await tabPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_source_opened_in_new_tab_via_context_menu() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#related-in-page", + { + button: 2, + type: "contextmenu", + }, + tab1.linkedBrowser + ); + await popupShownPromise; + + let openLinkInNewTabMenuItem = contextMenu.querySelector( + "#context-openlinkintab" + ); + contextMenu.activateItem(openLinkInNewTabMenuItem); + + let tab2 = await tabPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task( + async function test_source_refinement_button_clicked_no_partner_code() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); + } +); + +add_task( + async function test_source_refinement_button_clicked_with_partner_code() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button-with-partner-code", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); + } +); + +// When a user opens a refinement button link in a new tab, we want the +// source to be recorded as "follow_on_from_refine_on_SERP", not +// "opened_in_new_tab", since the refinement button click provides greater +// context. +add_task(async function test_refinement_button_vs_opened_in_new_tab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test2&abc=ff"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button-with-partner-code", + { button: 1 }, + tab1.linkedBrowser + ); + let tab2 = await tabPromise; + + await TestUtils.waitForCondition(() => { + return Glean.serp.impression?.testGetValue()?.length == 2; + }, "Should have two impressions."); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/search/test/browser/browser_search_telemetry_sources_navigation.js b/browser/components/search/test/browser/browser_search_telemetry_sources_navigation.js new file mode 100644 index 0000000000..4269649992 --- /dev/null +++ b/browser/components/search/test/browser/browser_search_telemetry_sources_navigation.js @@ -0,0 +1,549 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and link clicking. + */ + +"use strict"; + +const { SearchSERPTelemetry, SearchSERPTelemetryUtils } = + ChromeUtils.importESModule("resource:///modules/SearchSERPTelemetry.sys.mjs"); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/searchTelemetry(?:Ad)?.html/, + queryParamName: "s", + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getPageUrl(useAdPage = false) { + let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html"; + return `https://example.org/browser/browser/components/search/test/browser/${page}`; +} + +/** + * Returns the index of the first search suggestion in the urlbar results. + * + * @returns {number} An index, or -1 if there are no search suggestions. + */ +async function getFirstSuggestionIndex() { + const matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + return i; + } + } + return -1; +} + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +function resetTelemetry() { + searchCounts.clear(); + Services.telemetry.clearScalars(); + Services.fog.testResetFOG(); +} + +SearchTestUtils.init(this); + +let tab; + +add_setup(async function () { + searchCounts.clear(); + Services.telemetry.clearScalars(); + + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + Services.prefs.setBoolPref("browser.search.log", true); + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + Services.prefs.clearUserPref("browser.search.log"); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + Services.telemetry.clearScalars(); + }); +}); + +// These tests are consecutive and intentionally build on the results of the +// previous test. + +async function loadSearchPage() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; +} + +add_task(async function test_search() { + Services.fog.testResetFOG(); + // Load a page via the address bar. + await loadSearchPage(); + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + ]); +}); + +add_task(async function test_reload() { + let promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + tab.linkedBrowser.reload(); + await promise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.reload": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.reload": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "reload", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + ]); + await promiseAdImpressionReceived(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.reload": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.reload": { "example:tagged": 1 }, + "browser.search.adclicks.reload": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "reload", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); +}); + +let searchUrl; + +add_task(async function test_fresh_search() { + resetTelemetry(); + + // Load a page via the address bar. + await loadSearchPage(); + + searchUrl = tab.linkedBrowser.url; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + ]); + await promiseAdImpressionReceived(1); +}); + +add_task(async function test_click_ad() { + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); +}); + +add_task(async function test_go_back() { + let promise = BrowserTestUtils.waitForLocationChange(gBrowser, searchUrl); + tab.linkedBrowser.goBack(); + await promise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.tabhistory": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.tabhistory": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + ]); + await promiseAdImpressionReceived(2); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.tabhistory": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.tabhistory": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar": { "example:tagged": 1 }, + "browser.search.adclicks.tabhistory": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); +}); + +// Conduct a search from the Urlbar with showSearchTerms enabled. +add_task(async function test_fresh_search_with_urlbar_persisted() { + resetTelemetry(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.tipShownCount.searchTip_persist", 999], + ], + }); + + // Load a SERP once in order to show the search term in the Urlbar. + await loadSearchPage(); + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + ]); + await promiseAdImpressionReceived(1); + + // Do another search from the context of the default SERP. + await loadSearchPage(); + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + "other-Example.urlbar-persisted": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.content.urlbar_persisted": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar_persisted": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar_persisted", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + ]); + await promiseAdImpressionReceived(2); + + // Click on an ad. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + + await pageLoadPromise; + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + "other-Example.urlbar-persisted": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.content.urlbar_persisted": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar_persisted": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar_persisted": { "example:tagged": 1 }, + } + ); + + assertImpressionEvents([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar_persisted", + shopping_tab_displayed: "false", + is_shopping_page: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_addEngine.js b/browser/components/search/test/browser/browser_searchbar_addEngine.js new file mode 100644 index 0000000000..7d72d63dab --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_addEngine.js @@ -0,0 +1,99 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests the Add Search Engine option in the search bar. + */ + +"use strict"; + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +const searchPopup = document.getElementById("PopupSearchAutoComplete"); +let searchbar; + +add_setup(async function () { + searchbar = await gCUITestUtils.addSearchBar(); + + registerCleanupFunction(async function () { + gCUITestUtils.removeSearchBar(); + Services.search.restoreDefaultEngines(); + }); +}); + +add_task(async function test_invalidEngine() { + let rootDir = getRootDirectory(gTestPath); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + rootDir + "opensearch.html" + ); + let promise = promiseEvent(searchPopup, "popupshown"); + await EventUtils.synthesizeMouseAtCenter( + searchbar.querySelector(".searchbar-search-button"), + {} + ); + await promise; + + let addEngineList = searchPopup.querySelectorAll( + ".searchbar-engine-one-off-add-engine" + ); + let item = addEngineList[addEngineList.length - 1]; + + await TestUtils.waitForCondition( + () => item.tooltipText.includes("engineInvalid"), + "Wait until the tooltip will be correct" + ); + Assert.ok(true, "Last item should be the invalid entry"); + + let promptPromise = PromptTestUtils.waitForPrompt(tab.linkedBrowser, { + modalType: Ci.nsIPromptService.MODAL_TYPE_CONTENT, + promptType: "alert", + }); + + await EventUtils.synthesizeMouseAtCenter(item, {}); + + let prompt = await promptPromise; + + Assert.ok( + prompt.ui.infoBody.textContent.includes( + "http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine_404.xml" + ), + "Should have included the url in the prompt body" + ); + + await PromptTestUtils.handlePrompt(prompt); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_onOnlyDefaultEngine() { + info("Remove engines except default"); + const defaultEngine = Services.search.defaultEngine; + const engines = await Services.search.getVisibleEngines(); + for (const engine of engines) { + if (defaultEngine.name !== engine.name) { + await Services.search.removeEngine(engine); + } + } + + info("Show popup"); + const rootDir = getRootDirectory(gTestPath); + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + rootDir + "opensearch.html" + ); + const onShown = promiseEvent(searchPopup, "popupshown"); + await EventUtils.synthesizeMouseAtCenter( + searchbar.querySelector(".searchbar-search-button"), + {} + ); + await onShown; + + const addEngineList = searchPopup.querySelectorAll( + ".searchbar-engine-one-off-add-engine" + ); + Assert.equal(addEngineList.length, 3, "Add engines should be shown"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_context.js b/browser/components/search/test/browser/browser_searchbar_context.js new file mode 100644 index 0000000000..4a3d20fc50 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_context.js @@ -0,0 +1,246 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests the context menu for the search bar. + */ + +"use strict"; + +let win; + +XPCOMUtils.defineLazyServiceGetter( + this, + "clipboardHelper", + "@mozilla.org/widget/clipboardhelper;1", + "nsIClipboardHelper" +); + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + win = await BrowserTestUtils.openNewBrowserWindow(); + + // Disable suggestions for this test, so that we are not attempting to hit + // the network for suggestions when we don't need them. + SpecialPowers.pushPrefEnv({ + set: [["browser.search.suggest.enabled", false]], + }); + + registerCleanupFunction(async function () { + await BrowserTestUtils.closeWindow(win); + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_emptybar() { + const searchbar = win.BrowserSearch.searchBar; + searchbar.focus(); + + let contextMenu = searchbar.querySelector(".textbox-contextmenu"); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + await EventUtils.synthesizeMouseAtCenter( + searchbar, + { type: "contextmenu", button: 2 }, + win + ); + await contextMenuPromise; + + Assert.ok( + contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled, + "Should have disabled the cut menuitem" + ); + Assert.ok( + contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled, + "Should have disabled the copy menuitem" + ); + Assert.ok( + contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled, + "Should have disabled the delete menuitem" + ); + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; +}); + +add_task(async function test_text_in_bar() { + const searchbar = win.BrowserSearch.searchBar; + searchbar.focus(); + + searchbar.value = "Test"; + searchbar._textbox.editor.selectAll(); + + let contextMenu = searchbar.querySelector(".textbox-contextmenu"); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + await EventUtils.synthesizeMouseAtCenter( + searchbar, + { type: "contextmenu", button: 2 }, + win + ); + await contextMenuPromise; + + Assert.ok( + !contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled, + "Should have enabled the cut menuitem" + ); + Assert.ok( + !contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled, + "Should have enabled the copy menuitem" + ); + Assert.ok( + !contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled, + "Should have enabled the delete menuitem" + ); + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; +}); + +add_task(async function test_unfocused_emptybar() { + const searchbar = win.BrowserSearch.searchBar; + // clear searchbar value from previous test + searchbar.value = ""; + + // force focus onto another component + win.gURLBar.focus(); + + let contextMenu = searchbar.querySelector(".textbox-contextmenu"); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + searchbar.focus(); + await EventUtils.synthesizeMouseAtCenter( + searchbar, + { type: "contextmenu", button: 2 }, + win + ); + await contextMenuPromise; + + Assert.ok( + contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled, + "Should have disabled the cut menuitem" + ); + Assert.ok( + contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled, + "Should have disabled the copy menuitem" + ); + Assert.ok( + contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled, + "Should have disabled the delete menuitem" + ); + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; +}); + +add_task(async function test_text_in_unfocused_bar() { + const searchbar = win.BrowserSearch.searchBar; + + searchbar.value = "Test"; + + // force focus onto another component + win.gURLBar.focus(); + + let contextMenu = searchbar.querySelector(".textbox-contextmenu"); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + + searchbar.focus(); + await EventUtils.synthesizeMouseAtCenter( + searchbar, + { type: "contextmenu", button: 2 }, + win + ); + await contextMenuPromise; + + Assert.ok( + !contextMenu.getElementsByAttribute("cmd", "cmd_cut")[0].disabled, + "Should have enabled the cut menuitem" + ); + Assert.ok( + !contextMenu.getElementsByAttribute("cmd", "cmd_copy")[0].disabled, + "Should have enabled the copy menuitem" + ); + Assert.ok( + !contextMenu.getElementsByAttribute("cmd", "cmd_delete")[0].disabled, + "Should have enabled the delete menuitem" + ); + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + contextMenu.hidePopup(); + await popupHiddenPromise; +}); + +add_task(async function test_paste_and_go() { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser: win.gBrowser, + }); + + const searchbar = win.BrowserSearch.searchBar; + + searchbar.value = ""; + searchbar.focus(); + + const searchString = "test"; + + await SimpleTest.promiseClipboardChange(searchString, () => { + clipboardHelper.copyString(searchString); + }); + + let contextMenu = searchbar.querySelector(".textbox-contextmenu"); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await EventUtils.synthesizeMouseAtCenter( + searchbar, + { type: "contextmenu", button: 2 }, + win + ); + await contextMenuPromise; + + let popupHiddenPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + searchbar.querySelector(".searchbar-paste-and-search").click(); + await p; + contextMenu.hidePopup(); + await popupHiddenPromise; + + Assert.equal( + tab.linkedBrowser.currentURI.spec, + `https://example.com/?q=${searchString}`, + "Should have loaded the expected search page." + ); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_default.js b/browser/components/search/test/browser/browser_searchbar_default.js new file mode 100644 index 0000000000..c1e9280932 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_default.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests the correct default engines in the search bar. + */ + +"use strict"; + +const { SearchSuggestionController } = ChromeUtils.importESModule( + "resource://gre/modules/SearchSuggestionController.sys.mjs" +); + +const templateNormal = "https://example.com/?q="; +const templatePrivate = "https://example.com/?query="; + +const searchPopup = document.getElementById("PopupSearchAutoComplete"); + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault", false]], + }); + + // Create two new search engines. Mark one as the default engine, so + // the test don't crash. We need to engines for this test as the searchbar + // doesn't display the default search engine among the one-off engines. + await SearchTestUtils.installSearchExtension({ + name: "MozSearch1", + keyword: "mozalias", + }); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch2", + keyword: "mozalias2", + search_url_get_params: "query={searchTerms}", + }); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", false], + ], + }); + + let originalEngine = await Services.search.getDefault(); + let originalPrivateEngine = await Services.search.getDefaultPrivate(); + + let engineDefault = Services.search.getEngineByName("MozSearch1"); + await Services.search.setDefault( + engineDefault, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + registerCleanupFunction(async function () { + gCUITestUtils.removeSearchBar(); + await Services.search.setDefault( + originalEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.setDefaultPrivate( + originalPrivateEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + }); +}); + +async function doSearch( + win, + tab, + engineName, + templateUrl, + inputText = "query" +) { + await searchInSearchbar(inputText, win); + + Assert.ok( + win.BrowserSearch.searchBar.textbox.popup.searchbarEngineName + .getAttribute("value") + .includes(engineName), + "Should have the correct engine name displayed in the bar" + ); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter", {}, win); + await p; + + Assert.equal( + tab.linkedBrowser.currentURI.spec, + templateUrl + inputText, + "Should have loaded the expected search page." + ); +} + +add_task(async function test_default_search() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await doSearch(window, tab, "MozSearch1", templateNormal); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_default_search_private_no_separate() { + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await doSearch(win, win.gBrowser.selectedTab, "MozSearch1", templateNormal); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_default_search_private_no_separate() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.separatePrivateDefault", true]], + }); + + await Services.search.setDefaultPrivate( + Services.search.getEngineByName("MozSearch2"), + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + await doSearch(win, win.gBrowser.selectedTab, "MozSearch2", templatePrivate); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_form_history() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + await FormHistoryTestUtils.clear("searchbar-history"); + const gShortString = new Array( + SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + ) + .fill("a") + .join(""); + let promiseAdd = TestUtils.topicObserved("satchel-storage-changed"); + await doSearch(window, tab, "MozSearch1", templateNormal, gShortString); + await promiseAdd; + let entries = (await FormHistoryTestUtils.search("searchbar-history")).map( + entry => entry.value + ); + Assert.deepEqual( + entries, + [gShortString], + "Should have stored search history" + ); + + await FormHistoryTestUtils.clear("searchbar-history"); + const gLongString = new Array( + SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + 1 + ) + .fill("a") + .join(""); + await doSearch(window, tab, "MozSearch1", templateNormal, gLongString); + // There's nothing we can wait for, since addition should not be happening. + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 500)); + entries = (await FormHistoryTestUtils.search("searchbar-history")).map( + entry => entry.value + ); + Assert.deepEqual(entries, [], "Should not find form history"); + + await FormHistoryTestUtils.clear("searchbar-history"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_searchbar_revert() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await doSearch(window, tab, "MozSearch1", templateNormal, "testQuery"); + + let searchbar = window.BrowserSearch.searchBar; + is( + searchbar.value, + "testQuery", + "Search value should be the the last search" + ); + + // focus search bar + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + searchbar.focus(); + await promise; + + searchbar.value = "aQuery"; + searchbar.value = "anotherQuery"; + + // close the panel using the escape key. + promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await promise; + + is(searchbar.value, "anotherQuery", "The search value should be the same"); + // revert the search bar value + EventUtils.synthesizeKey("KEY_Escape"); + is( + searchbar.value, + "testQuery", + "The search value should have been reverted" + ); + + EventUtils.synthesizeKey("KEY_Escape"); + is(searchbar.value, "testQuery", "The search value should be the same"); + + await doSearch(window, tab, "MozSearch1", templateNormal, "query"); + + is(searchbar.value, "query", "The search value should be query"); + EventUtils.synthesizeKey("KEY_Escape"); + is(searchbar.value, "query", "The search value should be the same"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_enter.js b/browser/components/search/test/browser/browser_searchbar_enter.js new file mode 100644 index 0000000000..030cf26fb2 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_enter.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the behavior for enter key. + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + + await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); + + registerCleanupFunction(async function () { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function searchOnEnterSoon() { + info("Search on Enter as soon as typing a char"); + const win = await BrowserTestUtils.openNewBrowserWindow(); + const browser = win.gBrowser.selectedBrowser; + const browserSearch = win.BrowserSearch; + + const onPageHide = SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { + content.addEventListener("pagehide", () => { + resolve(); + }); + }); + }); + const onResult = SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { + content.addEventListener("keyup", () => { + resolve("keyup"); + }); + content.addEventListener("unload", () => { + resolve("unload"); + }); + }); + }); + + info("Focus on the search bar"); + const searchBarTextBox = browserSearch.searchBar.textbox; + EventUtils.synthesizeMouseAtCenter(searchBarTextBox, {}, win); + const ownerDocument = browser.ownerDocument; + is(ownerDocument.activeElement, searchBarTextBox, "The search bar has focus"); + + info("Keydown a char and Enter"); + EventUtils.synthesizeKey("x", { type: "keydown" }, win); + EventUtils.synthesizeKey("KEY_Enter", { type: "keydown" }, win); + + info("Wait for pagehide event in the content"); + await onPageHide; + is( + ownerDocument.activeElement, + searchBarTextBox, + "The search bar still has focus" + ); + + // Keyup both key as soon as pagehide event happens. + EventUtils.synthesizeKey("x", { type: "keyup" }, win); + EventUtils.synthesizeKey("KEY_Enter", { type: "keyup" }, win); + + await TestUtils.waitForCondition( + () => ownerDocument.activeElement === browser, + "Wait for focus to be moved to the browser" + ); + info("The focus is moved to the browser"); + + // Check whether keyup event is not captured before unload event happens. + const result = await onResult; + is(result, "unload", "Keyup event is not captured"); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function typeCharWhileProcessingEnter() { + info("Typing a char while processing enter key"); + const win = await BrowserTestUtils.openNewBrowserWindow(); + const browser = win.gBrowser.selectedBrowser; + const searchBar = win.BrowserSearch.searchBar; + + const SEARCH_WORD = "test"; + const onLoad = BrowserTestUtils.browserLoaded( + browser, + false, + `https://example.com/?q=${SEARCH_WORD}` + ); + searchBar.textbox.focus(); + searchBar.textbox.value = SEARCH_WORD; + + info("Keydown Enter"); + EventUtils.synthesizeKey("KEY_Enter", { type: "keydown" }, win); + await TestUtils.waitForCondition( + () => searchBar._needBrowserFocusAtEnterKeyUp, + "Wait for starting process for the enter key" + ); + + info("Keydown a char"); + EventUtils.synthesizeKey("x", { type: "keydown" }, win); + + info("Keyup both"); + EventUtils.synthesizeKey("x", { type: "keyup" }, win); + EventUtils.synthesizeKey("KEY_Enter", { type: "keyup" }, win); + + Assert.equal( + searchBar.textbox.value, + SEARCH_WORD, + "The value of searchbar is correct" + ); + + await onLoad; + Assert.ok("Browser loaded the correct url"); + + // Cleanup. + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function keyupEnterWhilePressingMeta() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const browser = win.gBrowser.selectedBrowser; + const searchBar = win.BrowserSearch.searchBar; + + info("Keydown Meta+Enter"); + searchBar.textbox.focus(); + searchBar.textbox.value = ""; + EventUtils.synthesizeKey( + "KEY_Enter", + { type: "keydown", metaKey: true }, + win + ); + + // Pressing Enter key while pressing Meta key, and next, even when releasing + // Enter key before releasing Meta key, the keyup event is not fired. + // Therefor, we fire Meta keyup event only. + info("Keyup Meta"); + EventUtils.synthesizeKey("KEY_Meta", { type: "keyup" }, win); + + await TestUtils.waitForCondition( + () => browser.ownerDocument.activeElement === browser, + "Wait for focus to be moved to the browser" + ); + info("The focus is moved to the browser"); + + info("Check whether we can input on the search bar"); + searchBar.textbox.focus(); + EventUtils.synthesizeKey("a", {}, win); + is(searchBar.textbox.value, "a", "Can input a char"); + + // Cleanup. + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js b/browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js new file mode 100644 index 0000000000..de78beabd6 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js @@ -0,0 +1,644 @@ +// Tests that keyboard navigation in the search panel works as designed. + +const searchPopup = document.getElementById("PopupSearchAutoComplete"); + +const kValues = ["foo1", "foo2", "foo3"]; +const kUserValue = "foo"; + +function getOpenSearchItems() { + let os = []; + + let addEngineList = searchPopup.searchOneOffsContainer.querySelector( + ".search-add-engines" + ); + for ( + let item = addEngineList.firstElementChild; + item; + item = item.nextElementSibling + ) { + os.push(item); + } + + return os; +} + +let searchbar; +let textbox; + +async function checkHeader(engine) { + // The header can be updated after getting the engine, so we may have to + // wait for it. + let header = searchPopup.searchbarEngineName; + if (!header.getAttribute("value").includes(engine.name)) { + await new Promise(resolve => { + let observer = new MutationObserver(() => { + observer.disconnect(); + resolve(); + }); + observer.observe(searchPopup.searchbarEngineName, { + attributes: true, + attributeFilter: ["value"], + }); + }); + } + Assert.ok( + header.getAttribute("value").includes(engine.name), + "Should have the correct engine name displayed in the header" + ); +} + +add_setup(async function () { + searchbar = await gCUITestUtils.addSearchBar(); + textbox = searchbar.textbox; + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine.xml", + setAsDefault: true, + }); + // First cleanup the form history in case other tests left things there. + info("cleanup the search history"); + await FormHistory.update({ op: "remove", fieldname: "searchbar-history" }); + + info("adding search history values: " + kValues); + let addOps = kValues.map(value => { + return { op: "add", fieldname: "searchbar-history", value }; + }); + await FormHistory.update(addOps); + + textbox.value = kUserValue; + + registerCleanupFunction(async () => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_arrows() { + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + searchbar.focus(); + await promise; + is( + textbox.mController.searchString, + kUserValue, + "The search string should be 'foo'" + ); + + // Check the initial state of the panel before sending keyboard events. + is(searchPopup.matchCount, kValues.length, "There should be 3 suggestions"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // The tests will be less meaningful if the first, second, last, and + // before-last one-off buttons aren't different. We should always have more + // than 4 default engines, but it's safer to check this assumption. + let oneOffs = getOneOffs(); + ok(oneOffs.length >= 4, "we have at least 4 one-off buttons displayed"); + + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // The down arrow should first go through the suggestions. + for (let i = 0; i < kValues.length; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + searchPopup.selectedIndex, + i, + "the suggestion at index " + i + " should be selected" + ); + is( + textbox.value, + kValues[i], + "the textfield value should be " + kValues[i] + ); + await checkHeader(Services.search.defaultEngine); + } + + // Pressing down again should remove suggestion selection and change the text + // field value back to what the user typed, and select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is( + textbox.value, + kUserValue, + "the textfield value should be back to initial value" + ); + + // now cycle through the one-off items, the first one is already selected. + for (let i = 0; i < oneOffs.length; ++i) { + let oneOffButton = oneOffs[i]; + is( + textbox.selectedButton, + oneOffButton, + "the one-off button #" + (i + 1) + " should be selected" + ); + await checkHeader(oneOffButton.engine); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + await checkHeader(Services.search.defaultEngine); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // We should now be back to the initial situation. + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + ok(!textbox.selectedButton, "no one-off button should be selected"); + await checkHeader(Services.search.defaultEngine); + + info("now test the up arrow key"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + await checkHeader(Services.search.defaultEngine); + + // cycle through the one-off items, the first one is already selected. + for (let i = oneOffs.length; i; --i) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + let oneOffButton = oneOffs[i - 1]; + is( + textbox.selectedButton, + oneOffButton, + "the one-off button #" + i + " should be selected" + ); + await checkHeader(oneOffButton.engine); + } + + // Another press on up should clear the one-off selection and select the + // last suggestion. + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + for (let i = kValues.length - 1; i >= 0; --i) { + is( + searchPopup.selectedIndex, + i, + "the suggestion at index " + i + " should be selected" + ); + is( + textbox.value, + kValues[i], + "the textfield value should be " + kValues[i] + ); + await checkHeader(Services.search.defaultEngine); + EventUtils.synthesizeKey("KEY_ArrowUp"); + } + + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is( + textbox.value, + kUserValue, + "the textfield value should be back to initial value" + ); +}); + +add_task(async function test_typing_clears_button_selection() { + is( + Services.focus.focusedElement, + textbox, + "the search bar should be focused" + ); // from the previous test. + ok(!textbox.selectedButton, "no button should be selected"); + + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // Type a character. + EventUtils.sendString("a"); + ok(!textbox.selectedButton, "the settings item should be de-selected"); + + // Remove the character. + EventUtils.synthesizeKey("KEY_Backspace"); +}); + +add_task(async function test_tab() { + is( + Services.focus.focusedElement, + textbox, + "the search bar should be focused" + ); // from the previous test. + + let oneOffs = getOneOffs(); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // Pressing tab should select the first one-off without selecting suggestions. + // now cycle through the one-off items, the first one is already selected. + for (let i = 0; i < oneOffs.length; ++i) { + EventUtils.synthesizeKey("KEY_Tab"); + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + } + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, kUserValue, "the textfield value should be unmodified"); + + // One more <tab> selects the settings button. + EventUtils.synthesizeKey("KEY_Tab"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // Pressing tab again should close the panel... + let promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await promise; + + // ... and move the focus out of the searchbox. + isnot( + Services.focus.focusedElement, + textbox.inputField, + "the search bar no longer be focused" + ); +}); + +add_task(async function test_shift_tab() { + // First reopen the panel. + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + searchbar.focus(); + await promise; + + let oneOffs = getOneOffs(); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // Press up once to select the last button. + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // Press up again to select the last one-off button. + EventUtils.synthesizeKey("KEY_ArrowUp"); + + // Pressing shift+tab should cycle through the one-off items. + for (let i = oneOffs.length - 1; i >= 0; --i) { + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + if (i) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + } + } + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, kUserValue, "the textfield value should be unmodified"); + + // Pressing shift+tab again should close the panel... + promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + await promise; + + // ... and move the focus out of the searchbox. + isnot( + Services.focus.focusedElement, + textbox.inputField, + "the search bar no longer be focused" + ); +}); + +add_task(async function test_alt_down() { + // First refocus the panel. + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + searchbar.focus(); + await promise; + + // close the panel using the escape key. + promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await promise; + + // check that alt+down opens the panel... + promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + await promise; + + // ... and does nothing else. + ok(!textbox.selectedButton, "no one-off button should be selected"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, kUserValue, "the textfield value should be unmodified"); + + // Pressing alt+down should select the first one-off without selecting suggestions + // and cycle through the one-off items. + let oneOffs = getOneOffs(); + for (let i = 0; i < oneOffs.length; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + } + + // One more alt+down keypress and nothing should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // another one and the first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[0], + "the first one-off button should be selected" + ); +}); + +add_task(async function test_alt_up() { + // close the panel using the escape key. + let promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await promise; + ok( + !textbox.selectedButton, + "no one-off button should be selected after closing the panel" + ); + + // check that alt+up opens the panel... + promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + await promise; + + // ... and does nothing else. + ok(!textbox.selectedButton, "no one-off button should be selected"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, kUserValue, "the textfield value should be unmodified"); + + // Pressing alt+up should select the last one-off without selecting suggestions + // and cycle up through the one-off items. + let oneOffs = getOneOffs(); + for (let i = oneOffs.length - 1; i >= 0; --i) { + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + } + + // One more alt+down keypress and nothing should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // another one and the last one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[oneOffs.length - 1], + "the last one-off button should be selected" + ); + + // Cleanup for the next test. + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok(!textbox.selectedButton, "no one-off should be selected anymore"); +}); + +add_task(async function test_accel_down() { + // Pressing accel+down should select the next visible search engine, without + // selecting suggestions. + let engines = await Services.search.getVisibleEngines(); + let current = Services.search.defaultEngine; + let currIdx = -1; + for (let i = 0, l = engines.length; i < l; ++i) { + if (engines[i].name == current.name) { + currIdx = i; + break; + } + } + for (let i = 0, l = engines.length; i < l; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown", { accelKey: true }); + await SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + let expected = engines[++currIdx % engines.length]; + is( + Services.search.defaultEngine.name, + expected.name, + "Default engine should have changed" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + } + Services.search.defaultEngine = current; +}); + +add_task(async function test_accel_up() { + // Pressing accel+down should select the previous visible search engine, without + // selecting suggestions. + let engines = await Services.search.getVisibleEngines(); + let current = Services.search.defaultEngine; + let currIdx = -1; + for (let i = 0, l = engines.length; i < l; ++i) { + if (engines[i].name == current.name) { + currIdx = i; + break; + } + } + for (let i = 0, l = engines.length; i < l; ++i) { + EventUtils.synthesizeKey("KEY_ArrowUp", { accelKey: true }); + await SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + let expected = + engines[--currIdx < 0 ? (currIdx = engines.length - 1) : currIdx]; + is( + Services.search.defaultEngine.name, + expected.name, + "Default engine should have changed" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + } + Services.search.defaultEngine = current; +}); + +add_task(async function test_tab_and_arrows() { + // Check the initial state is as expected. + ok(!textbox.selectedButton, "no one-off button should be selected"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, kUserValue, "the textfield value should be unmodified"); + + // After pressing down, the first sugggestion should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is(searchPopup.selectedIndex, 0, "first suggestion should be selected"); + is(textbox.value, kValues[0], "the textfield value should have changed"); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // After pressing tab, the first one-off should be selected, + // and no suggestion should be selected. + let oneOffs = getOneOffs(); + EventUtils.synthesizeKey("KEY_Tab"); + is( + textbox.selectedButton, + oneOffs[0], + "the first one-off button should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // After pressing down, the second one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + textbox.selectedButton, + oneOffs[1], + "the second one-off button should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // After pressing right, the third one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowRight"); + is( + textbox.selectedButton, + oneOffs[2], + "the third one-off button should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // After pressing left, the second one-off should be selected again. + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is( + textbox.selectedButton, + oneOffs[1], + "the second one-off button should be selected again" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // After pressing up, the first one-off should be selected again. + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + textbox.selectedButton, + oneOffs[0], + "the first one-off button should be selected again" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // After pressing up again, the last suggestion should be selected. + // the textfield value back to the user-typed value, and still the first one-off + // selected. + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + searchPopup.selectedIndex, + kValues.length - 1, + "last suggestion should be selected" + ); + is( + textbox.value, + kValues[kValues.length - 1], + "the textfield value should match the suggestion" + ); + is(textbox.selectedButton, null, "no one-off button should be selected"); + + // Now pressing down should select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + textbox.selectedButton, + oneOffs[0], + "the first one-off button should be selected" + ); + is(searchPopup.selectedIndex, -1, "there should be no selected suggestion"); + + // Finally close the panel. + let promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; +}); + +add_task(async function test_open_search() { + let rootDir = getRootDirectory(gTestPath); + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + rootDir + "opensearch.html" + ); + + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + searchbar.focus(); + await promise; + + let engines = searchPopup.querySelectorAll( + ".searchbar-engine-one-off-add-engine" + ); + is(engines.length, 3, "the opensearch.html page exposes 3 engines"); + + // Check that there's initially no selection. + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + ok(!textbox.selectedButton, "no button should be selected"); + + // Pressing up once selects the setting button... + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // ...and then pressing up selects open search engines. + for (let i = engines.length; i; --i) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + let selectedButton = textbox.selectedButton; + is( + selectedButton, + engines[i - 1], + "the engine #" + i + " should be selected" + ); + ok( + selectedButton.classList.contains("searchbar-engine-one-off-add-engine"), + "the button is themed as an add engine" + ); + } + + // Pressing up again should select the last one-off button. + EventUtils.synthesizeKey("KEY_ArrowUp"); + const allOneOffs = getOneOffs(); + is( + textbox.selectedButton, + allOneOffs[allOneOffs.length - engines.length - 1], + "the last one-off button should be selected" + ); + + info("now check that the down key navigates open search items as expected"); + for (let i = 0; i < engines.length; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + textbox.selectedButton, + engines[i], + "the engine #" + (i + 1) + " should be selected" + ); + } + + // Pressing down on the last engine item selects the settings button. + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; + + gBrowser.removeCurrentTab(); +}); + +add_task(async function cleanup() { + info("removing search history values: " + kValues); + let removeOps = kValues.map(value => { + return { op: "remove", fieldname: "searchbar-history", value }; + }); + await FormHistory.update(removeOps); + + textbox.value = ""; +}); diff --git a/browser/components/search/test/browser/browser_searchbar_openpopup.js b/browser/components/search/test/browser/browser_searchbar_openpopup.js new file mode 100644 index 0000000000..a8fe11dbf4 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_openpopup.js @@ -0,0 +1,794 @@ +// Tests that the suggestion popup appears at the right times in response to +// focus and user events (mouse, keyboard, drop). + +const searchPopup = document.getElementById("PopupSearchAutoComplete"); +const kValues = ["long text", "long text 2", "long text 3"]; + +async function endCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") != "true") { + return true; + } + let eventPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "aftercustomization" + ); + aWindow.gCustomizeMode.exit(); + return eventPromise; +} + +async function startCustomizing(aWindow = window) { + if (aWindow.document.documentElement.getAttribute("customizing") == "true") { + return true; + } + let eventPromise = BrowserTestUtils.waitForEvent( + aWindow.gNavToolbox, + "customizationready" + ); + aWindow.gCustomizeMode.enter(); + return eventPromise; +} + +let searchbar; +let textbox; +let searchIcon; +let goButton; +let engine; + +add_setup(async function () { + searchbar = await gCUITestUtils.addSearchBar(); + textbox = searchbar.textbox; + searchIcon = searchbar.querySelector(".searchbar-search-button"); + goButton = searchbar.querySelector(".search-go-button"); + + engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine.xml", + setAsDefault: true, + }); + + await clearSearchbarHistory(); + + let addOps = kValues.map(value => { + return { op: "add", fieldname: "searchbar-history", value }; + }); + info("adding search history values: " + kValues); + await FormHistory.update(addOps); + + registerCleanupFunction(async () => { + await clearSearchbarHistory(); + gCUITestUtils.removeSearchBar(); + }); +}); + +// Adds a task that shouldn't show the search suggestions popup. +function add_no_popup_task(task) { + add_task(async function () { + let sawPopup = false; + function listener() { + sawPopup = true; + } + + info("Entering test " + task.name); + searchPopup.addEventListener("popupshowing", listener); + await task(); + searchPopup.removeEventListener("popupshowing", listener); + ok(!sawPopup, "Shouldn't have seen the suggestions popup"); + info("Leaving test " + task.name); + }); +} + +// Simulates the full set of events for a context click +function context_click(target) { + for (let event of ["mousedown", "contextmenu", "mouseup"]) { + EventUtils.synthesizeMouseAtCenter(target, { type: event, button: 2 }); + } +} + +// Right clicking the icon should not open the popup. +add_no_popup_task(async function open_icon_context() { + gURLBar.focus(); + let toolbarPopup = document.getElementById("toolbar-context-menu"); + + let promise = promiseEvent(toolbarPopup, "popupshown"); + context_click(searchIcon); + await promise; + + promise = promiseEvent(toolbarPopup, "popuphidden"); + toolbarPopup.hidePopup(); + await promise; +}); + +// With no text in the search box left clicking the icon should open the popup. +// Clicking the icon again should hide the popup and not show it again. +add_task(async function open_empty() { + gURLBar.focus(); + + let promise = promiseEvent(searchPopup, "popupshown"); + info("Clicking icon"); + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + await promise; + is( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should only show the settings" + ); + is(textbox.mController.searchString, "", "Should be an empty search string"); + + let image = searchPopup.querySelector(".searchbar-engine-image"); + Assert.equal( + image.src, + engine.getIconURLBySize(16, 16), + "Should have the correct icon" + ); + + // By giving the textbox some text any next attempt to open the search popup + // from the click handler will try to search for this text. + textbox.value = "foo"; + + promise = promiseEvent(searchPopup, "popuphidden"); + + info("Hiding popup"); + await EventUtils.promiseNativeMouseEventAndWaitForEvent({ + type: "click", + target: searchIcon, + atCenter: true, + eventTypeToWait: "mouseup", + }); + await promise; + + is( + textbox.mController.searchString, + "", + "Should not have started to search for the new text" + ); + + // Cancel the search if it started. + if (textbox.mController.searchString != "") { + textbox.mController.stopSearch(); + } + + textbox.value = ""; +}); + +// With no text in the search box left clicking it should not open the popup. +add_no_popup_task(function click_doesnt_open_popup() { + gURLBar.focus(); + + EventUtils.synthesizeMouseAtCenter(textbox, {}); + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 0, "Should have selected all of the text"); +}); + +// Left clicking in a non-empty search box when unfocused should focus it and open the popup. +add_task(async function click_opens_popup() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(textbox, {}); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; + + textbox.value = ""; +}); + +add_task(async function open_empty_hiddenOneOffs() { + // Disable all the engines but the current one and check the oneoffs. + let defaultEngine = await Services.search.getDefault(); + let engines = (await Services.search.getVisibleEngines()).filter( + e => e.name != defaultEngine.name + ); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.hiddenOneOffs", engines.map(e => e.name).join(",")]], + }); + + textbox.value = "foo"; + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(textbox, {}); + await promise; + + Assert.ok( + searchPopup.searchOneOffsContainer.hasAttribute("hidden"), + "The one-offs buttons container should have the hidden attribute." + ); + Assert.ok( + BrowserTestUtils.is_hidden(searchPopup.searchOneOffsContainer), + "The one-off buttons container should be hidden." + ); + + promise = promiseEvent(searchPopup, "popuphidden"); + + info("Hiding popup"); + await EventUtils.promiseNativeMouseEventAndWaitForEvent({ + type: "click", + target: searchIcon, + atCenter: true, + eventTypeToWait: "mouseup", + }); + await promise; + + await SpecialPowers.popPrefEnv(); + textbox.value = ""; +}); + +// Right clicking in a non-empty search box when unfocused should open the edit context menu. +add_no_popup_task(async function right_click_doesnt_open_popup() { + gURLBar.focus(); + textbox.value = "foo"; + + // Can't wait for an event on the actual menu since it is created + // lazily the first time it is displayed. + let promise = new Promise(resolve => { + let listener = event => { + if (searchbar._menupopup && event.target == searchbar._menupopup) { + resolve(searchbar._menupopup); + } + }; + window.addEventListener("popupshown", listener); + }); + context_click(textbox); + let contextPopup = await promise; + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(contextPopup, "popuphidden"); + contextPopup.hidePopup(); + await promise; + + textbox.value = ""; +}); + +// Moving focus away from the search box should close the popup +add_task(async function focus_change_closes_popup() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(textbox, {}); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(searchPopup, "popuphidden"); + let promise2 = promiseEvent(searchbar.textbox, "blur"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + await promise; + await promise2; + + textbox.value = ""; +}); + +// Moving focus away from the search box should close the small popup +add_task(async function focus_change_closes_small_popup() { + gURLBar.focus(); + + let promise = promiseEvent(searchPopup, "popupshown"); + // For some reason sending the mouse event immediately doesn't open the popup. + SimpleTest.executeSoon(() => { + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + }); + await promise; + is( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the small popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + + promise = promiseEvent(searchPopup, "popuphidden"); + let promise2 = promiseEvent(searchbar.textbox, "blur"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + await promise; + await promise2; +}); + +// Pressing escape should close the popup. +add_task(async function escape_closes_popup() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(textbox, {}); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await promise; + + textbox.value = ""; +}); + +// Pressing contextmenu should close the popup. +add_task(async function contextmenu_closes_popup() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(textbox, {}); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + let contextPopup = searchbar._menupopup; + let contextMenuShownPromise = promiseEvent(contextPopup, "popupshown"); + let searchPopupHiddenPromise = promiseEvent(searchPopup, "popuphidden"); + context_click(textbox); + await contextMenuShownPromise; + await searchPopupHiddenPromise; + + let contextMenuHiddenPromise = promiseEvent(contextPopup, "popuphidden"); + contextPopup.hidePopup(); + await contextMenuHiddenPromise; + + textbox.value = ""; +}); + +// Tabbing to the search box should open the popup if it contains text. +add_task(async function tab_opens_popup() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeKey("KEY_Tab"); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; + + textbox.value = ""; +}); + +// Tabbing to the search box should not open the popup if it doesn't contain text. +add_no_popup_task(function tab_doesnt_open_popup() { + gURLBar.focus(); + textbox.value = "foo"; + + EventUtils.synthesizeKey("KEY_Tab"); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + textbox.value = ""; +}); + +// Switching back to the window when the search box has focus from mouse should not open the popup. +add_task(async function refocus_window_doesnt_open_popup_mouse() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(searchbar, {}); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(searchPopup, "popuphidden"); + let newWin = OpenBrowserWindow(); + await new Promise(resolve => waitForFocus(resolve, newWin)); + await promise; + + function listener() { + ok(false, "Should not have shown the popup."); + } + searchPopup.addEventListener("popupshowing", listener); + + promise = promiseEvent(searchbar.textbox, "focus"); + newWin.close(); + await promise; + + // Wait a few ticks to allow any focus handlers to show the popup if they are going to. + await new Promise(resolve => executeSoon(resolve)); + await new Promise(resolve => executeSoon(resolve)); + await new Promise(resolve => executeSoon(resolve)); + + searchPopup.removeEventListener("popupshowing", listener); + textbox.value = ""; +}); + +// Switching back to the window when the search box has focus from keyboard should not open the popup. +add_task(async function refocus_window_doesnt_open_popup_keyboard() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeKey("KEY_Tab"); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(searchPopup, "popuphidden"); + let newWin = OpenBrowserWindow(); + await new Promise(resolve => waitForFocus(resolve, newWin)); + await promise; + + function listener() { + ok(false, "Should not have shown the popup."); + } + searchPopup.addEventListener("popupshowing", listener); + + promise = promiseEvent(searchbar.textbox, "focus"); + newWin.close(); + await promise; + + // Wait a few ticks to allow any focus handlers to show the popup if they are going to. + await new Promise(resolve => executeSoon(resolve)); + await new Promise(resolve => executeSoon(resolve)); + await new Promise(resolve => executeSoon(resolve)); + + searchPopup.removeEventListener("popupshowing", listener); + textbox.value = ""; +}); + +// Clicking the search go button shouldn't open the popup +add_no_popup_task(async function search_go_doesnt_open_popup() { + gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser); + + gURLBar.focus(); + textbox.value = "foo"; + searchbar.updateGoButtonVisibility(); + + let promise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + EventUtils.synthesizeMouseAtCenter(goButton, {}); + await promise; + + textbox.value = ""; + gBrowser.removeCurrentTab(); +}); + +// Clicks outside the search popup should close the popup but not consume the click. +add_task(async function dont_consume_clicks() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(textbox, {}); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + is(textbox.selectionStart, 0, "Should have selected all of the text"); + is(textbox.selectionEnd, 3, "Should have selected all of the text"); + + promise = promiseEvent(searchPopup, "popuphidden"); + await EventUtils.promiseNativeMouseEventAndWaitForEvent({ + type: "click", + target: gURLBar.inputField, + atCenter: true, + eventTypeToWait: "mouseup", + }); + await promise; + + is( + Services.focus.focusedElement, + gURLBar.inputField, + "Should have focused the URL bar" + ); + + textbox.value = ""; +}); + +// Dropping text to the searchbar should open the popup +add_task(async function drop_opens_popup() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + // The previous task leaves focus in the URL bar. However, in that case drags + // can be interpreted as being selection drags by the drag manager, which + // breaks the drag synthesis from EventUtils.js below. To avoid this, focus + // the browser content instead. + let focusEventPromise = BrowserTestUtils.waitForEvent( + gBrowser.selectedBrowser, + "focus" + ); + gBrowser.selectedBrowser.focus(); + await focusEventPromise; + + let promise = promiseEvent(searchPopup, "popupshown"); + // Use a source for the drop that is outside of the search bar area, to avoid + // it receiving a mousedown and causing the popup to sometimes open. + let homeButton = document.getElementById("home-button"); + EventUtils.synthesizeDrop( + homeButton, + textbox, + [[{ type: "text/plain", data: "foo" }]], + "move", + window + ); + await promise; + + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + is( + Services.focus.focusedElement, + textbox, + "Should have focused the search bar" + ); + promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; + + textbox.value = ""; + CustomizableUI.removeWidgetFromArea("home-button"); +}); + +// Moving the caret using the cursor keys should not close the popup. +add_task(async function dont_rollup_oncaretmove() { + gURLBar.focus(); + textbox.value = "long text"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeMouseAtCenter(textbox, {}); + await promise; + + // Deselect the text + EventUtils.synthesizeKey("KEY_ArrowRight"); + is( + textbox.selectionStart, + 9, + "Should have moved the caret (selectionStart after deselect right)" + ); + is( + textbox.selectionEnd, + 9, + "Should have moved the caret (selectionEnd after deselect right)" + ); + is(searchPopup.state, "open", "Popup should still be open"); + + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is( + textbox.selectionStart, + 8, + "Should have moved the caret (selectionStart after left)" + ); + is( + textbox.selectionEnd, + 8, + "Should have moved the caret (selectionEnd after left)" + ); + is(searchPopup.state, "open", "Popup should still be open"); + + EventUtils.synthesizeKey("KEY_ArrowRight"); + is( + textbox.selectionStart, + 9, + "Should have moved the caret (selectionStart after right)" + ); + is( + textbox.selectionEnd, + 9, + "Should have moved the caret (selectionEnd after right)" + ); + is(searchPopup.state, "open", "Popup should still be open"); + + // Ensure caret movement works while a suggestion is selected. + is(textbox.popup.selectedIndex, -1, "No selected item in list"); + EventUtils.synthesizeKey("KEY_ArrowDown"); + is(textbox.popup.selectedIndex, 0, "Selected item in list"); + is( + textbox.selectionStart, + 9, + "Should have moved the caret to the end (selectionStart after selection)" + ); + is( + textbox.selectionEnd, + 9, + "Should have moved the caret to the end (selectionEnd after selection)" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is( + textbox.selectionStart, + 8, + "Should have moved the caret again (selectionStart after left)" + ); + is( + textbox.selectionEnd, + 8, + "Should have moved the caret again (selectionEnd after left)" + ); + is(searchPopup.state, "open", "Popup should still be open"); + + EventUtils.synthesizeKey("KEY_ArrowLeft"); + is( + textbox.selectionStart, + 7, + "Should have moved the caret (selectionStart after left)" + ); + is( + textbox.selectionEnd, + 7, + "Should have moved the caret (selectionEnd after left)" + ); + is(searchPopup.state, "open", "Popup should still be open"); + + EventUtils.synthesizeKey("KEY_ArrowRight"); + is( + textbox.selectionStart, + 8, + "Should have moved the caret (selectionStart after right)" + ); + is( + textbox.selectionEnd, + 8, + "Should have moved the caret (selectionEnd after right)" + ); + is(searchPopup.state, "open", "Popup should still be open"); + + if (!navigator.platform.includes("Mac")) { + EventUtils.synthesizeKey("KEY_Home"); + is( + textbox.selectionStart, + 0, + "Should have moved the caret (selectionStart after home)" + ); + is( + textbox.selectionEnd, + 0, + "Should have moved the caret (selectionEnd after home)" + ); + is(searchPopup.state, "open", "Popup should still be open"); + } + + // Close the popup again + promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape"); + await promise; + + textbox.value = ""; +}); + +// Entering customization mode shouldn't open the popup. +add_task(async function dont_open_in_customization() { + gURLBar.focus(); + textbox.value = "foo"; + + let promise = promiseEvent(searchPopup, "popupshown"); + EventUtils.synthesizeKey("KEY_Tab"); + await promise; + isnot( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the full popup" + ); + + info("Entering customization mode"); + let sawPopup = false; + function listener() { + sawPopup = true; + } + searchPopup.addEventListener("popupshowing", listener); + await gCUITestUtils.openMainMenu(); + promise = promiseEvent(searchPopup, "popuphidden"); + await startCustomizing(); + await promise; + + searchPopup.removeEventListener("popupshowing", listener); + ok(!sawPopup, "Shouldn't have seen the suggestions popup"); + + await endCustomizing(); + textbox.value = ""; +}); + +add_task(async function cleanup() { + info("removing search history values: " + kValues); + let removeOps = kValues.map(value => { + return { op: "remove", fieldname: "searchbar-history", value }; + }); + FormHistory.update(removeOps); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_results.js b/browser/components/search/test/browser/browser_searchbar_results.js new file mode 100644 index 0000000000..95bb5674c7 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_results.js @@ -0,0 +1,60 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + await clearSearchbarHistory(); + + await SearchTestUtils.installSearchExtension( + { + id: "test", + name: "test", + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + registerCleanupFunction(async () => { + await clearSearchbarHistory(); + gCUITestUtils.removeSearchBar(); + }); +}); + +async function check_results(input, expected) { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "about:blank", + }, + async browser => { + let popup = await searchInSearchbar(input); + + const listItemElems = popup.richlistbox.querySelectorAll( + ".autocomplete-richlistitem" + ); + + Assert.deepEqual( + Array.from(listItemElems) + .filter(e => !e.collapsed) + .map(e => e.getAttribute("title")), + expected, + "Should have received the expected suggestions" + ); + + // Now visit the search to put an item in form history. + let p = BrowserTestUtils.browserLoaded(browser); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + } + ); +} + +add_task(async function test_utf8_results() { + await check_results("。", ["。foo", "。bar"]); + + // The first run added the entry into form history, check that is correct + // as well. + await check_results("。", ["。", "。foo", "。bar"]); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js b/browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js new file mode 100644 index 0000000000..e2ddadabd9 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js @@ -0,0 +1,449 @@ +// Tests that keyboard navigation in the search panel works as designed. + +const searchPopup = document.getElementById("PopupSearchAutoComplete"); + +const kValues = ["foo1", "foo2", "foo3"]; + +function getOpenSearchItems() { + let os = []; + + let addEngineList = searchPopup.querySelector(".search-add-engines"); + for ( + let item = addEngineList.firstElementChild; + item; + item = item.nextElementSibling + ) { + os.push(item); + } + + return os; +} + +let searchbar; +let textbox; +let searchIcon; + +add_setup(async function () { + searchbar = await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); + textbox = searchbar.textbox; + searchIcon = searchbar.querySelector(".searchbar-search-button"); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine.xml", + setAsDefault: true, + }); + + // First cleanup the form history in case other tests left things there. + info("cleanup the search history"); + await FormHistory.update({ op: "remove", fieldname: "searchbar-history" }); + + info("adding search history values: " + kValues); + let addOps = kValues.map(value => { + return { op: "add", fieldname: "searchbar-history", value }; + }); + await FormHistory.update(addOps); + + registerCleanupFunction(async () => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_arrows() { + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + await promise; + info( + "textbox.mController.searchString = " + textbox.mController.searchString + ); + is(textbox.mController.searchString, "", "The search string should be empty"); + + // Check the initial state of the panel before sending keyboard events. + is( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the small popup" + ); + // Having suggestions populated (but hidden) is important, because if there + // are none we can't ensure the keyboard events don't reach them. + is(searchPopup.matchCount, kValues.length, "There should be 3 suggestions"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // The tests will be less meaningful if the first, second, last, and + // before-last one-off buttons aren't different. We should always have more + // than 4 default engines, but it's safer to check this assumption. + let oneOffs = getOneOffs(); + ok(oneOffs.length >= 4, "we have at least 4 one-off buttons displayed"); + + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // Pressing should select the first one-off. + EventUtils.synthesizeKey("KEY_ArrowDown"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, "", "the textfield value should be unmodified"); + + // now cycle through the one-off items, the first one is already selected. + for (let i = 0; i < oneOffs.length; ++i) { + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + } + + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + + // We should now be back to the initial situation. + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + info("now test the up arrow key"); + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // cycle through the one-off items, the first one is already selected. + for (let i = oneOffs.length; i; --i) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + textbox.selectedButton, + oneOffs[i - 1], + "the one-off button #" + i + " should be selected" + ); + } + + // Another press on up should clear the one-off selection. + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok(!textbox.selectedButton, "no one-off button should be selected"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, "", "the textfield value should be unmodified"); +}); + +add_task(async function test_tab() { + is( + Services.focus.focusedElement, + textbox, + "the search bar should be focused" + ); // from the previous test. + + let oneOffs = getOneOffs(); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // Pressing tab should select the first one-off without selecting suggestions. + // now cycle through the one-off items, the first one is already selected. + for (let i = 0; i < oneOffs.length; ++i) { + EventUtils.synthesizeKey("KEY_Tab"); + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + } + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, "", "the textfield value should be unmodified"); + + // One more <tab> selects the settings button. + EventUtils.synthesizeKey("KEY_Tab"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // Pressing tab again should close the panel... + let promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab"); + await promise; + + // ... and move the focus out of the searchbox. + isnot( + Services.focus.focusedElement, + textbox, + "the search bar no longer be focused" + ); +}); + +add_task(async function test_shift_tab() { + // First reopen the panel. + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + SimpleTest.executeSoon(() => { + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + }); + await promise; + + let oneOffs = getOneOffs(); + ok(!textbox.selectedButton, "no one-off button should be selected"); + is( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the small popup" + ); + + // Press up once to select the last button. + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // Press up again to select the last one-off button. + EventUtils.synthesizeKey("KEY_ArrowUp"); + + // Pressing shift+tab should cycle through the one-off items. + for (let i = oneOffs.length - 1; i >= 0; --i) { + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + if (i) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + } + } + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, "", "the textfield value should be unmodified"); + + // Pressing shift+tab again should close the panel... + promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }); + await promise; + + // ... and move the focus out of the searchbox. + isnot( + Services.focus.focusedElement, + textbox, + "the search bar no longer be focused" + ); +}); + +add_task(async function test_alt_down() { + // First reopen the panel. + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + SimpleTest.executeSoon(() => { + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + }); + await promise; + + // and check it's in a correct initial state. + is( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the small popup" + ); + ok(!textbox.selectedButton, "no one-off button should be selected"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, "", "the textfield value should be unmodified"); + + // Pressing alt+down should select the first one-off without selecting suggestions + // and cycle through the one-off items. + let oneOffs = getOneOffs(); + for (let i = 0; i < oneOffs.length; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + } + + // One more alt+down keypress and nothing should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // another one and the first one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[0], + "the first one-off button should be selected" + ); + + // Clear the selection with an alt+up keypress + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + ok(!textbox.selectedButton, "no one-off button should be selected"); +}); + +add_task(async function test_alt_up() { + // Check the initial state of the panel + ok(!textbox.selectedButton, "no one-off button should be selected"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, "", "the textfield value should be unmodified"); + + // Pressing alt+up should select the last one-off without selecting suggestions + // and cycle up through the one-off items. + let oneOffs = getOneOffs(); + for (let i = oneOffs.length - 1; i >= 0; --i) { + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[i], + "the one-off button #" + (i + 1) + " should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + } + + // One more alt+down keypress and nothing should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + ok(!textbox.selectedButton, "no one-off button should be selected"); + + // another one and the last one-off should be selected. + EventUtils.synthesizeKey("KEY_ArrowUp", { altKey: true }); + is( + textbox.selectedButton, + oneOffs[oneOffs.length - 1], + "the last one-off button should be selected" + ); + + // Cleanup for the next test. + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok(!textbox.selectedButton, "no one-off should be selected anymore"); +}); + +add_task(async function test_tab_and_arrows() { + // Check the initial state is as expected. + ok(!textbox.selectedButton, "no one-off button should be selected"); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + is(textbox.value, "", "the textfield value should be unmodified"); + + // After pressing down, the first one-off should be selected. + let oneOffs = getOneOffs(); + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + textbox.selectedButton, + oneOffs[0], + "the first one-off button should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // After pressing tab, the second one-off should be selected. + EventUtils.synthesizeKey("KEY_Tab"); + is( + textbox.selectedButton, + oneOffs[1], + "the second one-off button should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // After pressing up, the first one-off should be selected again. + EventUtils.synthesizeKey("KEY_ArrowUp"); + is( + textbox.selectedButton, + oneOffs[0], + "the first one-off button should be selected" + ); + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + + // Finally close the panel. + let promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; +}); + +add_task(async function test_open_search() { + let rootDir = getRootDirectory(gTestPath); + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + rootDir + "opensearch.html" + ); + + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + await promise; + is( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should show the small popup" + ); + + let engines; + await TestUtils.waitForCondition(() => { + engines = searchPopup.querySelectorAll( + ".searchbar-engine-one-off-add-engine" + ); + return engines.length == 3; + }, "Should expose three engines"); + + // Check that there's initially no selection. + is(searchPopup.selectedIndex, -1, "no suggestion should be selected"); + ok(!textbox.selectedButton, "no button should be selected"); + + // Pressing up once selects the setting button... + EventUtils.synthesizeKey("KEY_ArrowUp"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + // ...and then pressing up selects open search engines. + for (let i = engines.length; i; --i) { + EventUtils.synthesizeKey("KEY_ArrowUp"); + let selectedButton = textbox.selectedButton; + is( + selectedButton, + engines[i - 1], + "the engine #" + i + " should be selected" + ); + ok( + selectedButton.classList.contains("searchbar-engine-one-off-add-engine"), + "the button is themed as an add engine" + ); + } + + // Pressing up again should select the last one-off button. + EventUtils.synthesizeKey("KEY_ArrowUp"); + const allOneOffs = getOneOffs(); + is( + textbox.selectedButton, + allOneOffs[allOneOffs.length - engines.length - 1], + "the last one-off button should be selected" + ); + + info("now check that the down key navigates open search items as expected"); + for (let i = 0; i < engines.length; ++i) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + is( + textbox.selectedButton, + engines[i], + "the engine #" + (i + 1) + " should be selected" + ); + } + + // Pressing down on the last engine item selects the settings button. + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + textbox.selectedButton.classList.contains("search-setting-button"), + "the settings item should be selected" + ); + + promise = promiseEvent(searchPopup, "popuphidden"); + searchPopup.hidePopup(); + await promise; + + gBrowser.removeCurrentTab(); +}); + +add_task(async function cleanup() { + info("removing search history values: " + kValues); + let removeOps = kValues.map(value => { + return { op: "remove", fieldname: "searchbar-history", value }; + }); + FormHistory.update(removeOps); +}); diff --git a/browser/components/search/test/browser/browser_searchbar_widths.js b/browser/components/search/test/browser/browser_searchbar_widths.js new file mode 100644 index 0000000000..3e17ebf833 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_widths.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that when the searchbar has a specific width, opening a new window +// honours that specific width. +add_task(async function test_searchbar_width_persistence() { + await gCUITestUtils.addSearchBar(); + registerCleanupFunction(async function () { + gCUITestUtils.removeSearchBar(); + }); + + // Really, we should use the splitter, but drag/drop is hard and fragile in + // tests, so let's just fake it real quick: + let container = BrowserSearch.searchBar.parentNode; + // There's no width attribute set initially, just grab the info from layout: + let oldWidth = container.getBoundingClientRect().width; + let newWidth = "" + Math.round(oldWidth * 2); + container.setAttribute("width", newWidth); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let otherBar = win.BrowserSearch.searchBar; + ok(otherBar, "Should have a search bar in the other window"); + if (otherBar) { + is( + otherBar.parentNode.getAttribute("width"), + newWidth, + "Should have matching width" + ); + } + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/search/test/browser/browser_tooManyEnginesOffered.js b/browser/components/search/test/browser/browser_tooManyEnginesOffered.js new file mode 100644 index 0000000000..89647f9854 --- /dev/null +++ b/browser/components/search/test/browser/browser_tooManyEnginesOffered.js @@ -0,0 +1,68 @@ +"use strict"; + +// This test makes sure that when a page offers many search engines, +// a limited number of add-engine items will be shown in the searchbar. + +const searchPopup = document.getElementById("PopupSearchAutoComplete"); + +add_task(async function test_setup() { + await gCUITestUtils.addSearchBar(); + + await Services.search.init(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test() { + let searchbar = BrowserSearch.searchBar; + + let rootDir = getRootDirectory(gTestPath); + let url = rootDir + "tooManyEnginesOffered.html"; + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + // Open the search popup. + let promise = promiseEvent(searchPopup, "popupshown"); + info("Opening search panel"); + searchbar.focus(); + // In TV we may try opening too early, when the searchbar is not ready yet. + await TestUtils.waitForCondition( + () => BrowserSearch.searchBar.textbox.controller.input, + "Wait for the searchbar controller to connect" + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + await promise; + + const addEngineList = searchPopup.oneOffButtons._getAddEngines(); + Assert.equal( + addEngineList.length, + 6, + "Expected number of engines retrieved from web page" + ); + + const displayedAddEngineList = + searchPopup.oneOffButtons.buttons.querySelectorAll( + ".searchbar-engine-one-off-add-engine" + ); + Assert.equal( + displayedAddEngineList.length, + searchPopup.oneOffButtons._maxInlineAddEngines, + "Expected number of engines displayed on popup" + ); + + for (let i = 0; i < displayedAddEngineList.length; i++) { + const engine = addEngineList[i]; + const item = displayedAddEngineList[i]; + Assert.equal( + item.getAttribute("engine-name"), + engine.title, + "Expected engine is displaying" + ); + } + + promise = promiseEvent(searchPopup, "popuphidden"); + EventUtils.synthesizeKey("KEY_Escape", {}, searchPopup.ownerGlobal); + await promise; + + gBrowser.removeCurrentTab(); +}); diff --git a/browser/components/search/test/browser/browser_trending_suggestions.js b/browser/components/search/test/browser/browser_trending_suggestions.js new file mode 100644 index 0000000000..5db741e816 --- /dev/null +++ b/browser/components/search/test/browser/browser_trending_suggestions.js @@ -0,0 +1,189 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const CONFIG_DEFAULT = [ + { + webExtension: { id: "basic@search.mozilla.org" }, + urls: { + trending: { + fullPath: + "https://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs", + query: "", + }, + }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, + { + webExtension: { id: "private@search.mozilla.org" }, + appliesTo: [{ included: { everywhere: true } }], + default: "yes", + }, +]; + +SearchTestUtils.init(this); + +add_setup(async () => { + // Use engines in test directory + let searchExtensions = getChromeDir(getResolvedURI(gTestPath)); + searchExtensions.append("search-engines"); + await SearchTestUtils.useMochitestEngines(searchExtensions); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.suggest.searches", true]], + }); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT); + + registerCleanupFunction(async () => { + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + }); +}); + +add_task(async function test_trending_results() { + await check_results({ + featureEnabled: true, + searchMode: "@basic ", + expectedResults: 2, + }); + await check_results({ + featureEnabled: true, + requireSearchModeEnabled: false, + expectedResults: 2, + }); + await check_results({ + featureEnabled: true, + requireSearchModeEnabled: false, + searchMode: "@basic ", + expectedResults: 2, + }); + await check_results({ + featureEnabled: false, + searchMode: "@basic ", + expectedResults: 0, + }); + await check_results({ + featureEnabled: false, + expectedResults: 0, + }); + await check_results({ + featureEnabled: false, + requireSearchModeEnabled: false, + expectedResults: 0, + }); + await check_results({ + featureEnabled: false, + requireSearchModeEnabled: false, + searchMode: "@basic ", + expectedResults: 0, + }); + + // The private engine is not configured with any trending url. + await check_results({ + featureEnabled: true, + searchMode: "@private ", + expectedResults: 0, + }); + + // Check we can configure the maximum number of results. + await check_results({ + featureEnabled: true, + searchMode: "@basic ", + maxResultsSearchMode: 5, + expectedResults: 5, + }); + await check_results({ + featureEnabled: true, + requireSearchModeEnabled: false, + maxResultsNoSearchMode: 5, + expectedResults: 5, + }); +}); + +add_task(async function test_trending_telemetry() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + waitForFocus: SimpleTest.waitForFocus, + }); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + }); + + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar(scalars, "urlbar.picked.trending", 0, 1); +}); + +async function check_results({ + featureEnabled = false, + requireSearchModeEnabled = true, + searchMode = "", + expectedResults = 0, + maxResultsSearchMode = 2, + maxResultsNoSearchMode = 2, +}) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.trending.maxResultsSearchMode", maxResultsSearchMode], + [ + "browser.urlbar.trending.maxResultsNoSearchMode", + maxResultsNoSearchMode, + ], + ["browser.urlbar.trending.featureGate", featureEnabled], + ["browser.urlbar.trending.requireSearchMode", requireSearchModeEnabled], + ], + }); + + // If we are not in a search mode and there are no results. The urlbar + // will not open. + if (!searchMode && !expectedResults) { + window.gURLBar.inputField.focus(); + Assert.ok(!UrlbarTestUtils.isPopupOpen(window)); + return; + } + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: searchMode, + waitForFocus: SimpleTest.waitForFocus, + }); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + expectedResults, + "We matched the expected number of results" + ); + + if (expectedResults) { + for (let i = 0; i < expectedResults; i++) { + let { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + Assert.equal(result.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(result.providerName, "SearchSuggestions"); + Assert.equal(result.payload.engine, "basic"); + Assert.equal(result.payload.trending, true); + } + } + + if (searchMode) { + await UrlbarTestUtils.exitSearchMode(window); + } + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + await SpecialPowers.popPrefEnv(); +} diff --git a/browser/components/search/test/browser/cacheable.html b/browser/components/search/test/browser/cacheable.html new file mode 100644 index 0000000000..8aac4a0f16 --- /dev/null +++ b/browser/components/search/test/browser/cacheable.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Cacheable Page</title> +</head> +<body> + <p>This page is cacheable.</p> +</body> +</html> diff --git a/browser/components/search/test/browser/cacheable.html^headers^ b/browser/components/search/test/browser/cacheable.html^headers^ new file mode 100644 index 0000000000..6f34caa8f2 --- /dev/null +++ b/browser/components/search/test/browser/cacheable.html^headers^ @@ -0,0 +1 @@ +Cache-Control: max-age=3600 diff --git a/browser/components/search/test/browser/contentSearchUI.html b/browser/components/search/test/browser/contentSearchUI.html new file mode 100644 index 0000000000..09abe822b2 --- /dev/null +++ b/browser/components/search/test/browser/contentSearchUI.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<html> +<head> +<meta charset="utf-8"> +<script type="application/javascript" + src="chrome://browser/content/contentSearchUI.js"> +</script> +<link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css"/> +<meta http-equiv="Content-Security-Policy" content="default-src data: chrome:; object-src 'none'"/> +</head> +<body> + +<div id="container"><input type="text" value=""/></div> + +<script src="chrome://mochitests/content/browser/browser/components/search/test/browser/contentSearchUI.js"> +</script> + +</body> +</html> diff --git a/browser/components/search/test/browser/contentSearchUI.js b/browser/components/search/test/browser/contentSearchUI.js new file mode 100644 index 0000000000..7ccf0b6a6d --- /dev/null +++ b/browser/components/search/test/browser/contentSearchUI.js @@ -0,0 +1,13 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* import-globals-from ../../content/contentSearchUI.js */ +var input = document.querySelector("input"); +var gController = new ContentSearchUIController( + input, + input.parentNode, + "test", + "test" +); diff --git a/browser/components/search/test/browser/discovery.html b/browser/components/search/test/browser/discovery.html new file mode 100644 index 0000000000..0c73d592fe --- /dev/null +++ b/browser/components/search/test/browser/discovery.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> + <head id="linkparent"> + <meta charset="utf-8"> + <title>Autodiscovery Test</title> + </head> + <body> + </body> +</html> diff --git a/browser/components/search/test/browser/google_codes/browser.ini b/browser/components/search/test/browser/google_codes/browser.ini new file mode 100644 index 0000000000..5496ddb5c9 --- /dev/null +++ b/browser/components/search/test/browser/google_codes/browser.ini @@ -0,0 +1,5 @@ +[DEFAULT] +prefs = + browser.search.region='DE' + +[../browser_google_behavior.js] diff --git a/browser/components/search/test/browser/head.js b/browser/components/search/test/browser/head.js new file mode 100644 index 0000000000..730ea45c12 --- /dev/null +++ b/browser/components/search/test/browser/head.js @@ -0,0 +1,395 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + ADLINK_CHECK_TIMEOUT_MS: + "resource:///actors/SearchSERPTelemetryChild.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + CustomizableUITestUtils: + "resource://testing-common/CustomizableUITestUtils.sys.mjs", + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + FormHistoryTestUtils: + "resource://testing-common/FormHistoryTestUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +let gCUITestUtils = new CustomizableUITestUtils(window); + +AddonTestUtils.initMochitest(this); +SearchTestUtils.init(this); + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Recursively compare two objects and check that every property of expectedObj has the same value + * on actualObj. + * + * @param {object} expectedObj + * The expected object to find. + * @param {object} actualObj + * The object to inspect. + * @param {string} name + * The name of the engine, used for test detail logging. + */ +function isSubObjectOf(expectedObj, actualObj, name) { + for (let prop in expectedObj) { + if (typeof expectedObj[prop] == "function") { + continue; + } + if (expectedObj[prop] instanceof Object) { + is( + actualObj[prop].length, + expectedObj[prop].length, + name + "[" + prop + "]" + ); + isSubObjectOf( + expectedObj[prop], + actualObj[prop], + name + "[" + prop + "]" + ); + } else { + is(actualObj[prop], expectedObj[prop], name + "[" + prop + "]"); + } + } +} + +function getLocale() { + return Services.locale.requestedLocale || undefined; +} + +function promiseEvent(aTarget, aEventName, aPreventDefault) { + function cancelEvent(event) { + if (aPreventDefault) { + event.preventDefault(); + } + + return true; + } + + return BrowserTestUtils.waitForEvent(aTarget, aEventName, false, cancelEvent); +} + +// Get an array of the one-off buttons. +function getOneOffs() { + let oneOffs = []; + let searchPopup = document.getElementById("PopupSearchAutoComplete"); + let oneOffsContainer = searchPopup.searchOneOffsContainer; + let oneOff = oneOffsContainer.querySelector(".search-panel-one-offs"); + for (oneOff = oneOff.firstChild; oneOff; oneOff = oneOff.nextSibling) { + if (oneOff.nodeType == Node.ELEMENT_NODE) { + oneOffs.push(oneOff); + } + } + return oneOffs; +} + +async function typeInSearchField(browser, text, fieldName) { + await SpecialPowers.spawn( + browser, + [[fieldName, text]], + async function ([contentFieldName, contentText]) { + // Put the focus on the search box. + let searchInput = content.document.getElementById(contentFieldName); + searchInput.focus(); + searchInput.value = contentText; + } + ); +} + +XPCOMUtils.defineLazyGetter(this, "searchCounts", () => { + return Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS"); +}); + +XPCOMUtils.defineLazyGetter(this, "SEARCH_AD_CLICK_SCALARS", () => { + const sources = [ + ...BrowserSearchTelemetry.KNOWN_SEARCH_SOURCES.values(), + "unknown", + ]; + return [ + ...sources.map(v => `browser.search.withads.${v}`), + ...sources.map(v => `browser.search.adclicks.${v}`), + ]; +}); + +// Ad links are processed after a small delay. We need to allow tests to wait +// for that before checking telemetry, otherwise the received values may be +// too small in some cases. +function promiseWaitForAdLinkCheck() { + return new Promise(resolve => + /* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */ + setTimeout(resolve, ADLINK_CHECK_TIMEOUT_MS) + ); +} + +async function assertSearchSourcesTelemetry( + expectedHistograms, + expectedScalars +) { + let histSnapshot = {}; + let scalars = {}; + + // This used to rely on the implied 100ms initial timer of + // TestUtils.waitForCondition. See bug 1515466. + await new Promise(resolve => setTimeout(resolve, 100)); + + await TestUtils.waitForCondition(() => { + histSnapshot = searchCounts.snapshot(); + return ( + Object.getOwnPropertyNames(histSnapshot).length == + Object.getOwnPropertyNames(expectedHistograms).length + ); + }, "should have the correct number of histograms"); + + if (Object.entries(expectedScalars).length) { + await TestUtils.waitForCondition(() => { + scalars = + Services.telemetry.getSnapshotForKeyedScalars("main", false).parent || + {}; + return Object.getOwnPropertyNames(expectedScalars).every( + scalar => scalar in scalars + ); + }, "should have the expected keyed scalars"); + } + + Assert.equal( + Object.getOwnPropertyNames(histSnapshot).length, + Object.getOwnPropertyNames(expectedHistograms).length, + "Should only have one key" + ); + + for (let [key, value] of Object.entries(expectedHistograms)) { + Assert.ok( + key in histSnapshot, + `Histogram should have the expected key: ${key}` + ); + Assert.equal( + histSnapshot[key].sum, + value, + `Should have counted the correct number of visits for ${key}` + ); + } + + for (let [name, value] of Object.entries(expectedScalars)) { + Assert.ok(name in scalars, `Scalar ${name} should have been added.`); + Assert.deepEqual( + scalars[name], + value, + `Should have counted the correct number of visits for ${name}` + ); + } + + for (let name of SEARCH_AD_CLICK_SCALARS) { + Assert.equal( + name in scalars, + name in expectedScalars, + `Should have matched ${name} in scalars and expectedScalars` + ); + } +} + +async function searchInSearchbar(inputText, win = window) { + await new Promise(r => waitForFocus(r, win)); + let sb = win.BrowserSearch.searchBar; + // Write the search query in the searchbar. + sb.focus(); + sb.value = inputText; + sb.textbox.controller.startSearch(inputText); + // Wait for the popup to show. + await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown"); + // And then for the search to complete. + await TestUtils.waitForCondition( + () => + sb.textbox.controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH, + "The search in the searchbar must complete." + ); + return sb.textbox.popup; +} + +function clearSearchbarHistory(win = window) { + info("cleanup the search history"); + return FormHistory.update({ op: "remove", fieldname: "searchbar-history" }); +} + +function resetTelemetry() { + searchCounts.clear(); + Services.telemetry.clearScalars(); + Services.fog.testResetFOG(); +} + +/** + * First checks that we get the correct number of recorded Glean impression events + * and the recorded Glean impression events have the correct keys and values. + * + * Then it checks that there are the the correct engagement events associated with the + * impression events. + * + * @param {Array} expectedEvents The expected impression events whose keys and + * values we use to validate the recorded Glean impression events. + */ +function assertImpressionEvents(expectedEvents) { + // A single test might run assertImpressionEvents more than once + // so the Set needs to be cleared or else the impression event + // check will throw. + const impressionIdsSet = new Set(); + + let recordedImpressions = Glean.serp.impression.testGetValue() ?? []; + + Assert.equal( + recordedImpressions.length, + expectedEvents.length, + "Should have the correct number of impressions." + ); + + // Assert the impression events. + for (let [idx, expectedEvent] of expectedEvents.entries()) { + let impressionId = recordedImpressions[idx].extra.impression_id; + Assert.ok( + UUID_REGEX.test(impressionId), + "Should have an impression_id with a valid UUID." + ); + + Assert.ok( + !impressionIdsSet.has(impressionId), + "Should have a unique impression_id." + ); + + impressionIdsSet.add(impressionId); + + // If we want to use deepEqual checks, we have to add the impressionId + // to each impression since they are randomly generated at runtime. + expectedEvent.impression.impression_id = impressionId; + + Assert.deepEqual( + recordedImpressions[idx].extra, + expectedEvent.impression, + "Should have matched impression values." + ); + + // Once the impression check is sufficient, add the impression_id to + // each of the expected engagements for later deep equal checks. + if (expectedEvent.engagements) { + for (let expectedEngagment of expectedEvent.engagements) { + expectedEngagment.impression_id = impressionId; + } + } + } + + // Group engagement events into separate array fetchable by their + // impression_id. + let recordedEngagements = Glean.serp.engagement.testGetValue() ?? []; + let idToEngagements = new Map(); + let totalExpectedEngagements = 0; + + for (let recordedEngagement of recordedEngagements) { + let impressionId = recordedEngagement.extra.impression_id; + Assert.ok( + impressionId, + "Should have an engagement event with an impression_id" + ); + + let arr = idToEngagements.get(impressionId) ?? []; + arr.push(recordedEngagement.extra); + + idToEngagements.set(impressionId, arr); + } + + // Assert the engagement events. + for (let expectedEvent of expectedEvents) { + let impressionId = expectedEvent.impression.impression_id; + let expectedEngagements = expectedEvent.engagements; + if (expectedEngagements) { + let recorded = idToEngagements.get(impressionId); + Assert.deepEqual( + recorded, + expectedEngagements, + "Should have matched engagement values." + ); + totalExpectedEngagements += expectedEngagements.length; + } + } + + Assert.equal( + recordedEngagements.length, + totalExpectedEngagements, + "Should have equal number of engagements." + ); +} + +function assertAdImpressionEvents(expectedAdImpressions) { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + let impressions = Glean.serp.impression.testGetValue() ?? []; + + Assert.equal(impressions.length, 1, "Should have a SERP impression event."); + Assert.equal( + adImpressions.length, + expectedAdImpressions.length, + "Should have equal number of ad impression events." + ); + + expectedAdImpressions = expectedAdImpressions.map(expectedAdImpression => { + expectedAdImpression.impression_id = impressions[0].extra.impression_id; + return expectedAdImpression; + }); + + for (let [index, expectedAdImpression] of expectedAdImpressions.entries()) { + Assert.deepEqual( + adImpressions[index]?.extra, + expectedAdImpression, + "Should have equal values for an ad impression." + ); + } +} + +function assertAbandonmentEvent(expectedAbandonment) { + let recordedAbandonment = Glean.serp.abandonment.testGetValue() ?? []; + + Assert.equal( + recordedAbandonment[0].extra.reason, + expectedAbandonment.abandonment.reason, + "Should have the correct abandonment reason." + ); +} + +async function promiseAdImpressionReceived(num) { + if (num) { + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length == num; + }, `Should have received ${num} ad impressions.`); + } + return TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); +} + +async function waitForPageWithAdImpressions() { + return new Promise(resolve => { + let listener = win => { + Services.obs.removeObserver( + listener, + "reported-page-with-ad-impressions" + ); + resolve(); + }; + Services.obs.addObserver(listener, "reported-page-with-ad-impressions"); + }); +} + +registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/search/test/browser/mozsearch.sjs b/browser/components/search/test/browser/mozsearch.sjs new file mode 100644 index 0000000000..bde867c93e --- /dev/null +++ b/browser/components/search/test/browser/mozsearch.sjs @@ -0,0 +1,11 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(req, resp) { + resp.setHeader("Content-Type", "text/html", false); + if (req.hasHeader("Origin") && req.getHeader("Origin") != "null") { + resp.write("error"); + return; + } + resp.write("hello world"); +} diff --git a/browser/components/search/test/browser/opensearch.html b/browser/components/search/test/browser/opensearch.html new file mode 100644 index 0000000000..00620e3bcc --- /dev/null +++ b/browser/components/search/test/browser/opensearch.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="search" type="application/opensearchdescription+xml" title="engine1" href="http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine.xml"> +<link rel="search" type="application/opensearchdescription+xml" title="engine2" href="http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine_mozsearch.xml"> +<link rel="search" type="application/opensearchdescription+xml" title="engineInvalid" href="http://mochi.test:8888/browser/browser/components/search/test/browser/testEngine_404.xml"> +</head> +<body></body> +</html> diff --git a/browser/components/search/test/browser/redirect_ad.sjs b/browser/components/search/test/browser/redirect_ad.sjs new file mode 100644 index 0000000000..36be567d3f --- /dev/null +++ b/browser/components/search/test/browser/redirect_ad.sjs @@ -0,0 +1,10 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_final.sjs", false); + response.setHeader("Cache-Control", "no-cache, must-revalidate", false); +} diff --git a/browser/components/search/test/browser/redirect_final.sjs b/browser/components/search/test/browser/redirect_final.sjs new file mode 100644 index 0000000000..14debde6ba --- /dev/null +++ b/browser/components/search/test/browser/redirect_final.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "https://example.com/hello_world", false); +} diff --git a/browser/components/search/test/browser/redirect_once.sjs b/browser/components/search/test/browser/redirect_once.sjs new file mode 100644 index 0000000000..d15f3afe6d --- /dev/null +++ b/browser/components/search/test/browser/redirect_once.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_final.sjs", false); +} diff --git a/browser/components/search/test/browser/redirect_thrice.sjs b/browser/components/search/test/browser/redirect_thrice.sjs new file mode 100644 index 0000000000..b7c7069162 --- /dev/null +++ b/browser/components/search/test/browser/redirect_thrice.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_twice.sjs", false); +} diff --git a/browser/components/search/test/browser/redirect_twice.sjs b/browser/components/search/test/browser/redirect_twice.sjs new file mode 100644 index 0000000000..099d20022e --- /dev/null +++ b/browser/components/search/test/browser/redirect_twice.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_once.sjs", false); +} diff --git a/browser/components/search/test/browser/search-engines/basic/manifest.json b/browser/components/search/test/browser/search-engines/basic/manifest.json new file mode 100644 index 0000000000..3bdb68fea1 --- /dev/null +++ b/browser/components/search/test/browser/search-engines/basic/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "basic", + "manifest_version": 2, + "version": "1.0", + "description": "basic", + "browser_specific_settings": { + "gecko": { + "id": "basic@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "basic", + "keyword": "@basic", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + } + } +} diff --git a/browser/components/search/test/browser/search-engines/private/manifest.json b/browser/components/search/test/browser/search-engines/private/manifest.json new file mode 100644 index 0000000000..69ef8b29ef --- /dev/null +++ b/browser/components/search/test/browser/search-engines/private/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "private", + "manifest_version": 2, + "version": "1.0", + "description": "A test private engine", + "browser_specific_settings": { + "gecko": { + "id": "private@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "private", + "keyword": "@private", + "search_url": "https://example.com", + "suggest_url": "https://example.com?search={searchTerms}" + } + } +} diff --git a/browser/components/search/test/browser/searchSuggestionEngine.sjs b/browser/components/search/test/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..515b56984a --- /dev/null +++ b/browser/components/search/test/browser/searchSuggestionEngine.sjs @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.importGlobalProperties(["TextEncoder"]); + +let gTimer; + +function handleRequest(req, resp) { + // Parse the query params. If the params aren't in the form "foo=bar", then + // treat the entire query string as a search string. + let params = req.queryString.split("&").reduce((memo, pair) => { + let [key, val] = pair.split("="); + if (!val) { + // This part isn't in the form "foo=bar". Treat it as the search string + // (the "query"). + val = key; + key = "query"; + } + memo[decode(key)] = decode(val); + return memo; + }, {}); + + let timeout = parseInt(params.timeout); + if (timeout) { + // Write the response after a timeout. + resp.processAsync(); + gTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + gTimer.init( + () => { + writeResponse(params, resp); + resp.finish(); + }, + timeout, + Ci.nsITimer.TYPE_ONE_SHOT + ); + return; + } + + writeResponse(params, resp); +} + +function writeResponse(params, resp) { + // Echo back the search string with "foo" and "bar" appended. + let suffixes = ["foo", "bar"]; + let data = [params.query, suffixes.map(s => params.query + s)]; + resp.setHeader("Content-Type", "application/json", false); + + let json = JSON.stringify(data); + let utf8 = String.fromCharCode(...new TextEncoder().encode(json)); + resp.write(utf8); +} + +function decode(str) { + return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" "))); +} diff --git a/browser/components/search/test/browser/searchTelemetry.html b/browser/components/search/test/browser/searchTelemetry.html new file mode 100644 index 0000000000..bd395d4a7c --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetry.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a href="https://example.com/otherpage">Non ad link</a> + <a href="https://example1.com/ad">Matching path prefix, different server</a> + <a href="https://mochi.test:8888/otherpage">Non ad link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd.html b/browser/components/search/test/browser/searchTelemetryAd.html new file mode 100644 index 0000000000..23d51d2fb5 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a id="ad1" href="https://example.com/ad">Ad link</a> + <a id="ad2" href="https://example.com/ad2">Second Ad link</a> + <!-- The iframe is used to include a sub-document load in the test, which + should not affect the recorded telemetry. --> + <iframe src="searchTelemetry.html"></iframe> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel.html new file mode 100644 index 0000000000..71049be20c --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel.html @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + Carousels can have multiple hidden links. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <button type="button">Next</button> + </div> + <!-- + Carousels can be used for non-ads. + --> + <h5 test-label="true">non_ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Giraffes</h3> + </a> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Rhinos</h3> + </a> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_below_the_fold.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_below_the_fold.html new file mode 100644 index 0000000000..737e1e654b --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_below_the_fold.html @@ -0,0 +1,83 @@ +<!-- + This is for testing a carousel below the fold. +--> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top" style="padding-top: 1000px;"> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel-container"> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_doubled.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_doubled.html new file mode 100644 index 0000000000..f7b7f948d9 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_doubled.html @@ -0,0 +1,182 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + Carousels can have multiple hidden links. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel" narrow="true" id="second-ad"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + <!-- + Carousels can be used for non-ads. + --> + <h5 test-label="true">non_ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Giraffes</h3> + </a> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Rhinos</h3> + </a> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_first_element_non_visible.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_first_element_non_visible.html new file mode 100644 index 0000000000..b5a44b325e --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_first_element_non_visible.html @@ -0,0 +1,85 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + If a user scrolls a carousel before the impression is snapped, + we shouldn't count elements that aren't fully shown in the carousel + as visible. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel-container"> + <div class="moz-carousel" narrow="true"> + <div style="margin-left: -80px;" class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_hidden.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_hidden.html new file mode 100644 index 0000000000..cccd714326 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_hidden.html @@ -0,0 +1,87 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" + href="./serp.css" /> +</head> +<body> + <section id="top"> + <h5 test-label="true">ad_carousel with display: none;</h5> + <div class="moz-carousel" narrow="true" style="display: none;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + + <h5 test-label="true">ad_carousel with no width;</h5> + <div class="moz-carousel" narrow="true" style="width: 0;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + </div> + </div> + </div> + </div> + + <h5 test-label="true">ad_carousel with no height;</h5> + <div class="moz-carousel" narrow="true" style="height: 0;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + </div> + </div> + </div> + </div> + + <h5 test-label="true">ad_carousel that is far above the page</h5> + <div class="moz-carousel" narrow="true" style="position: absolute; top: -9999px;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_carousel_outer_container.html b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_outer_container.html new file mode 100644 index 0000000000..759bd9f0d9 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_carousel_outer_container.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + Carousels can sometimes have an outer container that doesn't always show up. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel-container"> + <div class="moz-carousel" extra="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_text.html b/browser/components/search/test/browser/searchTelemetryAd_components_text.html new file mode 100644 index 0000000000..bc1219bfa9 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_text.html @@ -0,0 +1,112 @@ +<!-- + Text ads reuse the data-ad element in multiple components to make it + difficult to determine which component it belongs to, similar to Bing. +--> +<!DOCTYPE html> +<html lang="en"> +<head> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="searchresults"> + <div class="lhs"> + <div class="moz_ad"> + <h5 test-label>ad_sitelink</h5> + <a href="https://example.com/ad/1"> + <h2>Example Result</h2> + </a> + <span><a href="https://example.com/ad/2">Ad link that says there are 10 Locations nearby</a></span> + <div class="multi-col"> + <div> + <a href="https://example.com/ad/3"> + <h2>New Releases</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + <div> + <a id="deep_ad_sitelink" href="https://example.com/ad/4"> + <h2>Men's</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + <div> + <a href="https://example.com/ad/5"> + <h2>Women's</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + <div> + <!-- Ensure ads encoded in data-attributes are also recorded properly --> + <a data-moz-attr="https://example.com/ad/6" href="https://example.com/normal-link"> + <h2>Sale</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + </div> + </div> + <div class="moz_ad"> + <h5 test-label>ad_link</h5> + <a id="ad_link_redirect" href="https://example.org/browser/browser/components/search/test/browser/redirect_ad.sjs"> + <h2>Example Shop</h2> + </a> + <div class="factrow"> + <a href="https://example.com/ad/8">Home Page</a> + <a href="https://example.com/ad/9">Products</a> + <a href="https://example.com/ad/10">Sales</a> + </div> + </div> + <div class="moz_ad"> + <h5 test-label>ad_link</h5> + <a href="https://example.com/ad/11"> + <h2>Example Shop</h2> + </a> + </div> + <div> + <h5 test-label>non_ads_link</h5> + <a id="non_ads_link" href="https://example.com/browser/browser/components/search/test/browser/cacheable.html"> + Example of a cached non ad + </a><br /> + <a id="non_ads_link_redirected" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html"> + Example of a redirected non ad link + </a><br /> + <a id="non_ads_link_redirected_no_top_level" href="#"> + Example of a redirected non ad link that isn't initially top level loaded + </a><br /> + <a id="non_ads_link_multiple_redirects" href="https://example.com/browser/browser/components/search/test/browser/redirect_thrice.sjs"> + Example of a redirected non ad link that's redirected multiple times + </a><br /> + <a id="non_ads_link_with_special_characters_in_path" href="https://example.com/path'?hello_world&foo=bar's"> + Example of a non ad with special characters in path + </a> + </div> + </div> + <div class="rhs"> + <h5 test-label>ad_sidebar</h5> + <div class="moz_ad"> + <a href="https://example.com/ad/15"> + <div class="mock-image">Mock ad image</div> + </a> + <a href="https://example.com/ad/16"> + <h3>Buy Example Now</h3> + </a> + <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</p> + <a href="https://example.com/ad/17">Buy Now</a> + </div> + </div> + </section> + <iframe style="display: none;"></iframe> + <script> + window.addEventListener("message", (event) => { + if (event.origin == "https://example.org") { + window.location.href = event.data; + } + }); + document.getElementById("non_ads_link_redirected_no_top_level") + .addEventListener("click", (event) => { + event.preventDefault(); + let iframe = document.querySelector("iframe"); + iframe.src = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html"; + }); + </script> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_components_visibility.html b/browser/components/search/test/browser/searchTelemetryAd_components_visibility.html new file mode 100644 index 0000000000..475ada3a3c --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_components_visibility.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <div style="display: flex; gap: 20px;"> + <div> + <h5 test-label="true">ad_link</h5> + <!-- The parent size exceeds the window height but the first ad link is above the fold. --> + <div class="moz_ad" style="padding-bottom: 2000px;"> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + <div> + <h5 test-label="true" >ad_link</h5> + <a href="https://example.com/ad">Ad Link</a> + </div> + <!-- The ad links are below the fold but the test will scroll to it before the impression is recorded. --> + <div> + <h5 test-label="true">ad_link</h5> + <div id="second-ad" class="moz_ad" style="padding-top: 2000px;"> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + <div> + <h5 test-label="true" style="margin-bottom: 2000px;">ad_link</h5> + <a href="https://example.com/ad">Ad Link</a> + </div> + <!-- The ad links are below the fold and shouldn't be viewed in the test. --> + <div> + <h5 test-label="true">ad_link</h5> + <div class="moz_ad" style="padding-top: 4000px;"> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + <div> + <h5 test-label="true" style="margin-bottom: 4000px;">ad_link</h5> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_dataAttributes.html b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes.html new file mode 100644 index 0000000000..7bc1b2745e --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes.html @@ -0,0 +1,10 @@ +<!-- This HTML file encodes the ad link in the data attribute --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a data-xyz="https://example.com/ad123" href="https://example.com/otherpage">Ad link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_href.html b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_href.html new file mode 100644 index 0000000000..319485cfae --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_href.html @@ -0,0 +1,10 @@ +<!-- This HTML file encodes the ad link in the href attribute and has irrelevant data in data attribute --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a data-xyz="https://example.com/otherpage" href="https://example.com/ad123">Ad link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_none.html b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_none.html new file mode 100644 index 0000000000..a119cf71be --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_dataAttributes_none.html @@ -0,0 +1,10 @@ +<!-- This HTML file has non-ad data in both the href and data attribute --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a data-xyz="https://example.com/otherpage" href="https://example.com/otherpage">Non-Ad Link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html new file mode 100644 index 0000000000..d987356d7e --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Page will do a redirect</title> + <meta content="0;url=https://example.com/hello_world" http-equiv="refresh"> +</head> +<body> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html new file mode 100644 index 0000000000..1c5c31cb38 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Page will do a redirect without doing it in a top load</title> + <!-- <meta content="0;url=https://example.com/hello_world" http-equiv="refresh"> --> + <script> + let parentWindow = window.parent; + let url = "https://example.com/hello_world"; + parentWindow.postMessage(url, "*"); + </script> + </head> + <body> + </body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ new file mode 100644 index 0000000000..419697b050 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ @@ -0,0 +1,4 @@ +Cache-Control: no-cache, must-revalidate +Pragma: no-cache +Expires: Fri, 01 Jan 1990 00:00:00 GMT +Content-Type: text/html; charset=ISO-8859-1 diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox.html b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html new file mode 100644 index 0000000000..ca38c13218 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section> + <form role="search"> + <input type="text" value="test" /> + <div> + <ul> + <li id="suggest">test</li> + </div> + </form> + </section> + <section id="searchresults"> + <div class="lhs"> + <div> + <h5 test-label>non_ads_link</h5> + <a id="non_ads_link" href="https://example.com/hello_world"> + <h2>Example of a non ad</h2> + </a> + </div> + </div> + </section> +</body> +<script type="text/javascript"> + document.querySelector("form").addEventListener("submit", event => { + event.preventDefault(); + window.location.href = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox.html?s=test&abc=ff"; + }) + document.getElementById("suggest").addEventListener("click", event => { + event.preventDefault(); + window.location.href = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox.html?s=test&abc=ff"; + }) +</script> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html^headers^ new file mode 100644 index 0000000000..62847d0585 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox.html^headers^ @@ -0,0 +1 @@ +Cache-Control: private, max-age=0 diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html new file mode 100644 index 0000000000..e381135561 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html @@ -0,0 +1,39 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section> + <form role="search"> + <input type="text" value="test" /> + </form> + </section> + <nav> + <a id="images" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test&page=images">Images</a> + <a id="shopping" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test&page=shopping">Shopping</a> + <a id="extra" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox.html?s=test">Extra Page</a> + </nav> + <section class="refined-search-buttons"> + <a id="refined-search-button" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test's">Test's</a> + <a id="refined-search-button-with-partner-code" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test2&abc=ff">Test 2</a> + </section> + <section id="searchresults"> + <div class="lhs"> + <div> + <h2>Related Searches</h2> + <a id="related-new-tab" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three" target="_blank">test one two three</a> + <a id="related-redirect" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html" target="_blank">test one two three</a> + <a id="related-in-page" href="https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three">test one two three</a> + </div> + </div> + </section> +</body> +<script type="text/javascript"> + document.querySelector("form").addEventListener("submit", event => { + event.preventDefault(); + window.location.href = "https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test&abc=ff"; + }); +</script> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html new file mode 100644 index 0000000000..901dd54a55 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Page will do a redirect</title> + <meta content="0;url=https://example.org/browser/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three" http-equiv="refresh"> +</head> +<body> +</body> +</html> diff --git a/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/searchTelemetryAd_shopping.html b/browser/components/search/test/browser/searchTelemetryAd_shopping.html new file mode 100644 index 0000000000..faa6c057a4 --- /dev/null +++ b/browser/components/search/test/browser/searchTelemetryAd_shopping.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <nav> + <a href="https://example.org/search?q=something&page=images&foo=bar">Images</a> + <a id="shopping" href="https://example.org/search?q=something&page=shopping&foo=bar">Shopping</a> + </nav> +</body> +</html> diff --git a/browser/components/search/test/browser/serp.css b/browser/components/search/test/browser/serp.css new file mode 100644 index 0000000000..5b3865da44 --- /dev/null +++ b/browser/components/search/test/browser/serp.css @@ -0,0 +1,164 @@ +:root { + --margin-left: 80px; + --subtle: whitesmoke; + --carousel-card-width: 180px; +} + +body { + margin: 0; + padding: 0 0 80px 0; +} + +a:link { + text-decoration: none; +} + +a:visited { + color: blue; +} + +h5[test-label] { + margin-top: 30px; + margin-bottom: 4px; +} + +nav { + border-bottom: 1px solid #ececec; + padding-bottom: 20px; + margin-bottom: 20px; +} + +#searchform { + padding-top: 20px; + margin-bottom: 20px; +} + +nav>div, +#searchform, +.moz-carousel, +.factrow { + display: flex; + align-items: center; +} + +nav>div, +#searchform { + gap: 40px; +} + +nav>div, +#searchform, +#searchresults, +#top { + margin-left: var(--margin-left); +} + +#searchbox { + font-size: 14px; + padding: 10px 20px; + width: 300px; + border-radius: 20px; + border: 2px solid var(--subtle); + height: 20px; +} + +.card-container { + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; +} + +.card-container>.card { + height: 160px; + border-radius: 3px; + border: 1px solid var(--subtle); + display: inline-block; + box-sizing: border-box; + padding: 10px; +} + +.card-container>.card:not(:last-child) { + margin-right: 10px; +} + +.card-container>.card>a { + display: block; + margin-bottom: 2px; +} + +#searchresults { + width: 900px; + display: grid; + grid-template-columns: 600px 300px; +} + +.moz-carousel, +.factrow { + gap: 10px; +} + +.moz-carousel { + overflow: hidden; +} + +.moz-carousel[narrow], +.moz-carousel-container { + width: calc(var(--carousel-card-width) * 3 + (3 * 10px)); + overflow-x: auto; +} + +.moz-carousel[extra] { + width: calc(var(--carousel-card-width) * 4 + (3 * 10px)); +} + +.moz-carousel>.moz-inner { + border: 1px solid var(--subtle); + border-radius: 10px; + padding: 10px; +} + +.moz-carousel>.moz-carousel-card { + flex: 1 0 var(--carousel-card-width); + border: 1px solid var(--subtle); + font-size: 14px; +} + +.moz-carousel-card .moz-carousel-image { + width: 100%; + height: 120px; + background-color: var(--subtle); + display: flex; + align-items: center; + justify-content: center; +} + +.moz-carousel-card-inner-content { + padding: 10px 20px 20px 20px; +} + +.multi-col { + display: grid; + padding: 10px 20px 20px 20px; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.mock-image { + height: 100px; + background-color: var(--subtle); + display: flex; + align-items: center; + justify-content: center; +} + +/* Some SERPs hide anchors using CSS */ +.hidden { + display: none; +} + +/* Typography */ +h2 { + line-height: 100%; + margin-bottom: 10px; + margin-top: 10px; +} diff --git a/browser/components/search/test/browser/slow_loading_page_with_ads.html b/browser/components/search/test/browser/slow_loading_page_with_ads.html new file mode 100644 index 0000000000..35ac9878ec --- /dev/null +++ b/browser/components/search/test/browser/slow_loading_page_with_ads.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a id="ad1" href="https://example.com/ad">Ad link</a> + <a id="ad2" href="https://example.com/ad2">Second Ad link</a> + <!-- The iframe is used to include a sub-document load in the test, which + should not affect the recorded telemetry. --> + <iframe src="searchTelemetry.html"></iframe> + <img src="https://example.org/browser/browser/components/search/test/browser/slow_loading_page_with_ads.sjs"> +</body> +</html> diff --git a/browser/components/search/test/browser/slow_loading_page_with_ads.sjs b/browser/components/search/test/browser/slow_loading_page_with_ads.sjs new file mode 100644 index 0000000000..7a6382d1cb --- /dev/null +++ b/browser/components/search/test/browser/slow_loading_page_with_ads.sjs @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + const DELAY_MS = 2000; + response.processAsync(); + + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "image/png", false); + response.write("Start loading image"); + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + response.write("Finish loading image"); + response.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/search/test/browser/slow_loading_page_with_ads_on_load_event.html b/browser/components/search/test/browser/slow_loading_page_with_ads_on_load_event.html new file mode 100644 index 0000000000..307b24d4fe --- /dev/null +++ b/browser/components/search/test/browser/slow_loading_page_with_ads_on_load_event.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body id='body'> +</body> + <img src="https://example.org/browser/browser/components/search/test/browser/slow_loading_page_with_ads.sjs"> + <script> + setTimeout(() => { + let body = document.getElementById('body'); + let ad1 = document.createElement('a'); + ad1.setAttribute('id', 'ad1'); + ad1.setAttribute('href', 'https://example.com/ad'); + ad1.innerHTML = 'Ad link' + + let ad2 = document.createElement('a'); + ad2.setAttribute('id', 'ad2'); + ad2.setAttribute('href', 'https://example.com/ad2'); + ad2.innerHTML = 'Second Ad link' + + let frame = document.createElement('iframe'); + frame.setAttribute('src', 'searchTelemetry.html'); + + body.appendChild(ad1); + body.appendChild(ad2); + body.appendChild(frame); + }, 2000); + </script> +</html> diff --git a/browser/components/search/test/browser/telemetrySearchSuggestions.sjs b/browser/components/search/test/browser/telemetrySearchSuggestions.sjs new file mode 100644 index 0000000000..1978b4f665 --- /dev/null +++ b/browser/components/search/test/browser/telemetrySearchSuggestions.sjs @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/components/search/test/browser/telemetrySearchSuggestions.xml b/browser/components/search/test/browser/telemetrySearchSuggestions.xml new file mode 100644 index 0000000000..057fc70bf5 --- /dev/null +++ b/browser/components/search/test/browser/telemetrySearchSuggestions.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_UsageTelemetry usageTelemetrySearchSuggestions.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/telemetrySearchSuggestions.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://example.com" rel="searchform"/> +</SearchPlugin> diff --git a/browser/components/search/test/browser/test.html b/browser/components/search/test/browser/test.html new file mode 100644 index 0000000000..a39bece4ff --- /dev/null +++ b/browser/components/search/test/browser/test.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8" /> + <title>Bug 426329</title> +</head> +<body></body> +</html> diff --git a/browser/components/search/test/browser/testEngine.xml b/browser/components/search/test/browser/testEngine.xml new file mode 100644 index 0000000000..9c25993232 --- /dev/null +++ b/browser/components/search/test/browser/testEngine.xml @@ -0,0 +1,12 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>Foo</ShortName> + <Description>Foo Search</Description> + <InputEncoding>utf-8</InputEncoding> + <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search"> + <Param name="test" value="{searchTerms}"/> + </Url> + <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm> + <moz:Alias>fooalias</moz:Alias> +</OpenSearchDescription> diff --git a/browser/components/search/test/browser/testEngine_diacritics.xml b/browser/components/search/test/browser/testEngine_diacritics.xml new file mode 100644 index 0000000000..340893348d --- /dev/null +++ b/browser/components/search/test/browser/testEngine_diacritics.xml @@ -0,0 +1,12 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>Foo ♡</ShortName> + <Description>Engine whose ShortName contains non-BMP Unicode characters</Description> + <InputEncoding>utf-8</InputEncoding> + <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search"> + <Param name="test" value="{searchTerms}"/> + </Url> + <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm> + <moz:Alias>diacriticalias</moz:Alias> +</OpenSearchDescription> diff --git a/browser/components/search/test/browser/testEngine_dupe.xml b/browser/components/search/test/browser/testEngine_dupe.xml new file mode 100644 index 0000000000..86c4cfadaf --- /dev/null +++ b/browser/components/search/test/browser/testEngine_dupe.xml @@ -0,0 +1,12 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>FooDupe</ShortName> + <Description>Second Engine Search</Description> + <InputEncoding>utf-8</InputEncoding> + <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?search"> + <Param name="test" value="{searchTerms}"/> + </Url> + <moz:SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</moz:SearchForm> + <moz:Alias>secondalias</moz:Alias> +</OpenSearchDescription> diff --git a/browser/components/search/test/browser/testEngine_mozsearch.xml b/browser/components/search/test/browser/testEngine_mozsearch.xml new file mode 100644 index 0000000000..2f285feb4c --- /dev/null +++ b/browser/components/search/test/browser/testEngine_mozsearch.xml @@ -0,0 +1,14 @@ +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> + <ShortName>Foo</ShortName> + <Description>Foo Search</Description> + <InputEncoding>utf-8</InputEncoding> + <Image width="16" height="16">data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAABGklEQVQoz2NgGB6AnZ1dUlJSXl4eSDIyMhLW4Ovr%2B%2Fr168uXL69Zs4YoG%2BLi4i5dusTExMTGxsbNzd3f37937976%2BnpmZmagbHR09J49e5YvX66kpATVEBYW9ubNm2nTphkbG7e2tp44cQLIuHfvXm5urpaWFlDKysqqu7v73LlzECMYIiIiHj58mJCQoKKicvXq1bS0NKBgW1vbjh074uPjgeqAXE1NzSdPnvDz84M0AEUvXLgAsW379u1z5swBen3jxo2zZ892cHB4%2BvQp0KlAfwI1cHJyghQFBwfv2rULokFXV%2FfixYu7d%2B8GGqGgoMDKyrpu3br9%2B%2FcDuXl5eVA%2FAEWBfoWHAdAYoNuAYQ0XAeoUERFhGDYAAPoUaT2dfWJuAAAAAElFTkSuQmCC</Image> + <Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/?suggestions&locale={moz:locale}&test={searchTerms}"/> + <Url type="text/html" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/"> + <Param name="test" value="{searchTerms}"/> + <Param name="ie" value="utf-8"/> + <MozParam name="channel" condition="purpose" purpose="keyword" value="keywordsearch"/> + <MozParam name="channel" condition="purpose" purpose="contextmenu" value="contextsearch"/> + </Url> + <SearchForm>http://mochi.test:8888/browser/browser/components/search/test/browser/</SearchForm> +</SearchPlugin> diff --git a/browser/components/search/test/browser/test_search.html b/browser/components/search/test/browser/test_search.html new file mode 100644 index 0000000000..010d1fdc82 --- /dev/null +++ b/browser/components/search/test/browser/test_search.html @@ -0,0 +1 @@ +test%20search diff --git a/browser/components/search/test/browser/tooManyEnginesOffered.html b/browser/components/search/test/browser/tooManyEnginesOffered.html new file mode 100644 index 0000000000..64e48d05e9 --- /dev/null +++ b/browser/components/search/test/browser/tooManyEnginesOffered.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<link rel="search" type="application/opensearchdescription+xml" title="engine1" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine1.xml"> +<link rel="search" type="application/opensearchdescription+xml" title="engine2" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine2.xml"> +<link rel="search" type="application/opensearchdescription+xml" title="engine3" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine3.xml"> +<link rel="search" type="application/opensearchdescription+xml" title="engine4" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine4.xml"> +<link rel="search" type="application/opensearchdescription+xml" title="engine5" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine5.xml"> +<link rel="search" type="application/opensearchdescription+xml" title="engine6" href="http://mochi.test:8888/browser/browser/components/search/test/browser/engine6.xml"> +</head> +<body></body> +</html> diff --git a/browser/components/search/test/browser/trendingSuggestionEngine.sjs b/browser/components/search/test/browser/trendingSuggestionEngine.sjs new file mode 100644 index 0000000000..c568cc223b --- /dev/null +++ b/browser/components/search/test/browser/trendingSuggestionEngine.sjs @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.importGlobalProperties(["TextEncoder"]); + +let gTimer; + +function handleRequest(req, resp) { + // Parse the query params. If the params aren't in the form "foo=bar", then + // treat the entire query string as a search string. + let params = req.queryString.split("&").reduce((memo, pair) => { + let [key, val] = pair.split("="); + if (!val) { + // This part isn't in the form "foo=bar". Treat it as the search string + // (the "query"). + val = key; + key = "query"; + } + memo[decode(key)] = decode(val); + return memo; + }, {}); + + writeResponse(params, resp); +} + +function writeResponse(params, resp) { + // Echoes back 15 results, query0, query1, query2 etc. + let suffixes = [...Array(15).keys()]; + let query = params.query || ""; + let data = [query, suffixes.map(s => query + s)]; + if (params?.richsuggestions) { + data.push([]); + data.push({ + "google:suggestdetail": suffixes.map(s => ({ + a: "Extended title", + dc: "#FFFFFF", + i: "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==", + t: "Title", + })), + }); + } + resp.setHeader("Content-Type", "application/json", false); + + let json = JSON.stringify(data); + let utf8 = String.fromCharCode(...new TextEncoder().encode(json)); + resp.write(utf8); +} + +function decode(str) { + return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" "))); +} diff --git a/browser/components/search/test/marionette/manifest.ini b/browser/components/search/test/marionette/manifest.ini new file mode 100644 index 0000000000..3ca0ae0eb5 --- /dev/null +++ b/browser/components/search/test/marionette/manifest.ini @@ -0,0 +1,4 @@ +[DEFAULT] +run-if = buildapp == 'browser' + +[test_engines_on_restart.py] diff --git a/browser/components/search/test/marionette/test_engines_on_restart.py b/browser/components/search/test/marionette/test_engines_on_restart.py new file mode 100644 index 0000000000..d7a0634e75 --- /dev/null +++ b/browser/components/search/test/marionette/test_engines_on_restart.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import textwrap + +from marionette_harness.marionette_test import MarionetteTestCase + + +class TestEnginesOnRestart(MarionetteTestCase): + def setUp(self): + super(TestEnginesOnRestart, self).setUp() + self.marionette.enforce_gecko_prefs( + { + "browser.search.log": True, + } + ) + + def get_default_search_engine(self): + """Retrieve the identifier of the default search engine.""" + + script = """\ + let [resolve] = arguments; + let searchService = Components.classes[ + "@mozilla.org/browser/search-service;1"] + .getService(Components.interfaces.nsISearchService); + return searchService.init().then(function () { + resolve(searchService.defaultEngine.identifier); + }); + """ + + with self.marionette.using_context(self.marionette.CONTEXT_CHROME): + return self.marionette.execute_async_script(textwrap.dedent(script)) + + def test_engines(self): + self.assertTrue(self.get_default_search_engine().startswith("google")) + self.marionette.set_pref("intl.locale.requested", "kk_KZ") + self.marionette.restart(clean=False, in_app=True) + self.assertTrue(self.get_default_search_engine().startswith("google")) diff --git a/browser/components/search/test/unit/test_search_telemetry_config_validation.js b/browser/components/search/test/unit/test_search_telemetry_config_validation.js new file mode 100644 index 0000000000..1c243cfc82 --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_config_validation.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TELEMETRY_SETTINGS_KEY: "resource:///modules/SearchSERPTelemetry.sys.mjs", + JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs", +}); + +/** + * Checks to see if a value is an object or not. + * + * @param {*} value + * The value to check. + * @returns {boolean} + */ +function isObject(value) { + return value != null && typeof value == "object" && !Array.isArray(value); +} + +/** + * This function modifies the schema to prevent allowing additional properties + * on objects. This is used to enforce that the schema contains everything that + * we deliver via the search configuration. + * + * These checks are not enabled in-product, as we want to allow older versions + * to keep working if we add new properties for whatever reason. + * + * @param {object} section + * The section to check to see if an additionalProperties flag should be added. + */ +function disallowAdditionalProperties(section) { + // It is generally acceptable for new properties to be added to the + // configuration as older builds will ignore them. + // + // As a result, we only check for new properties on nightly builds, and this + // avoids us having to uplift schema changes. This also helps preserve the + // schemas as documentation of "what was supported in this version". + if (!AppConstants.NIGHTLY_BUILD) { + info("Skipping additional properties validation."); + return; + } + + if (section.type == "object") { + section.additionalProperties = false; + } + for (let value of Object.values(section)) { + if (isObject(value)) { + disallowAdditionalProperties(value); + } + } +} + +add_task(async function test_search_config_validates_to_schema() { + let schema = await IOUtils.readJSON( + PathUtils.join(do_get_cwd().path, "search-telemetry-schema.json") + ); + disallowAdditionalProperties(schema); + + let data = await RemoteSettings(TELEMETRY_SETTINGS_KEY).get(); + + let validator = new JsonSchema.Validator(schema); + + for (let entry of data) { + // Records in Remote Settings contain additional properties independent of + // the schema. Hence, we don't want to validate their presence. + delete entry.schema; + delete entry.id; + delete entry.last_modified; + delete entry.filter_expression; + + let result = validator.validate(entry); + let message = `Should validate ${entry.telemetryId}`; + if (!result.valid) { + message += `:\n${JSON.stringify(result.errors, null, 2)}`; + } + Assert.ok(result.valid, message); + } +}); diff --git a/browser/components/search/test/unit/test_urlTelemetry.js b/browser/components/search/test/unit/test_urlTelemetry.js new file mode 100644 index 0000000000..bd46f39e5b --- /dev/null +++ b/browser/components/search/test/unit/test_urlTelemetry.js @@ -0,0 +1,310 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +const TESTS = [ + { + title: "Google search access point", + trackingUrl: + "https://www.google.com/search?q=test&ie=utf-8&oe=utf-8&client=firefox-b-1-ab", + expectedSearchCountEntry: "google:tagged:firefox-b-1-ab", + expectedAdKey: "google:tagged", + adUrls: [ + "https://www.googleadservices.com/aclk=foobar", + "https://www.googleadservices.com/pagead/aclk=foobar", + "https://www.google.com/aclk=foobar", + "https://www.google.com/pagead/aclk=foobar", + ], + nonAdUrls: [ + "https://www.googleadservices.com/?aclk=foobar", + "https://www.googleadservices.com/bar", + "https://www.google.com/image", + ], + }, + { + title: "Google search access point follow-on", + trackingUrl: + "https://www.google.com/search?client=firefox-b-1-ab&ei=EI_VALUE&q=test2&oq=test2&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:tagged-follow-on:firefox-b-1-ab", + }, + { + title: "Google organic", + trackingUrl: + "https://www.google.com/search?client=firefox-b-d-invalid&source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:organic:other", + expectedAdKey: "google:organic", + adUrls: ["https://www.googleadservices.com/aclk=foobar"], + nonAdUrls: ["https://www.googleadservices.com/?aclk=foobar"], + }, + { + title: "Google organic no code", + trackingUrl: + "https://www.google.com/search?source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:organic:none", + expectedAdKey: "google:organic", + adUrls: ["https://www.googleadservices.com/aclk=foobar"], + nonAdUrls: ["https://www.googleadservices.com/?aclk=foobar"], + }, + { + title: "Google organic UK", + trackingUrl: + "https://www.google.co.uk/search?source=hp&ei=EI_VALUE&q=test&oq=test&gs_l=GS_L_VALUE", + expectedSearchCountEntry: "google:organic:none", + }, + { + title: "Bing search access point", + trackingUrl: "https://www.bing.com/search?q=test&pc=MOZI&form=MOZLBR", + expectedSearchCountEntry: "bing:tagged:MOZI", + expectedAdKey: "bing:tagged", + adUrls: [ + "https://www.bing.com/aclick?ld=foo", + "https://www.bing.com/aclk?ld=foo", + ], + nonAdUrls: [ + "https://www.bing.com/fd/ls/ls.gif?IG=foo", + "https://www.bing.com/fd/ls/l?IG=bar", + "https://www.bing.com/aclook?", + "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=baz&url=%2Fvideos%2Fsearch%3Fq%3Dfoo", + "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=bar&url=https%3A%2F%2Fwww.bing.com%2Faclick", + "https://www.bing.com/fd/ls/GLinkPingPost.aspx?IG=bar&url=https%3A%2F%2Fwww.bing.com%2Faclk", + ], + }, + { + setUp() { + Services.cookies.removeAll(); + Services.cookies.add( + "www.bing.com", + "/", + "SRCHS", + "PC=MOZI", + false, + false, + false, + Date.now() + 1000 * 60 * 60, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + }, + tearDown() { + Services.cookies.removeAll(); + }, + title: "Bing search access point follow-on", + trackingUrl: + "https://www.bing.com/search?q=test&qs=n&form=QBRE&sp=-1&pq=&sc=0-0&sk=&cvid=CVID_VALUE", + expectedSearchCountEntry: "bing:tagged-follow-on:MOZI", + }, + { + title: "Bing organic", + trackingUrl: "https://www.bing.com/search?q=test&pc=MOZIfoo&form=MOZLBR", + expectedSearchCountEntry: "bing:organic:other", + expectedAdKey: "bing:organic", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: ["https://www.bing.com/fd/ls/ls.gif?IG=foo"], + }, + { + title: "Bing organic no code", + trackingUrl: + "https://www.bing.com/search?q=test&qs=n&form=QBLH&sp=-1&pq=&sc=0-0&sk=&cvid=CVID_VALUE", + expectedSearchCountEntry: "bing:organic:none", + expectedAdKey: "bing:organic", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: ["https://www.bing.com/fd/ls/ls.gif?IG=foo"], + }, + { + title: "DuckDuckGo search access point", + trackingUrl: "https://duckduckgo.com/?q=test&t=ffab", + expectedSearchCountEntry: "duckduckgo:tagged:ffab", + expectedAdKey: "duckduckgo:tagged", + adUrls: [ + "https://duckduckgo.com/y.js?ad_provider=foo", + "https://duckduckgo.com/y.js?f=bar&ad_provider=foo", + "https://www.amazon.co.uk/foo?tag=duckduckgo-ffab-uk-32-xk", + ], + nonAdUrls: [ + "https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images", + "https://duckduckgo.com/y.js?ifu=foo", + "https://improving.duckduckgo.com/t/bar", + ], + }, + { + title: "DuckDuckGo organic", + trackingUrl: "https://duckduckgo.com/?q=test&t=other&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:other", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "DuckDuckGo expected organic code", + trackingUrl: "https://duckduckgo.com/?q=test&t=h_&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:none", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "DuckDuckGo expected organic code 2", + trackingUrl: "https://duckduckgo.com/?q=test&t=hz&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:none", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "DuckDuckGo organic no code", + trackingUrl: "https://duckduckgo.com/?q=test&ia=news", + expectedSearchCountEntry: "duckduckgo:organic:none", + expectedAdKey: "duckduckgo:organic", + adUrls: ["https://duckduckgo.com/y.js?ad_provider=foo"], + nonAdUrls: ["https://duckduckgo.com/?q=foo&t=ffab&ia=images&iax=images"], + }, + { + title: "Baidu search access point", + trackingUrl: "https://www.baidu.com/baidu?wd=test&tn=monline_7_dg&ie=utf-8", + expectedSearchCountEntry: "baidu:tagged:monline_7_dg", + expectedAdKey: "baidu:tagged", + adUrls: ["https://www.baidu.com/baidu.php?url=encoded"], + nonAdUrls: ["https://www.baidu.com/link?url=encoded"], + }, + { + title: "Baidu search access point follow-on", + trackingUrl: + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&tn=monline_7_dg&wd=test2&oq=test&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn&rsv_enter=1&rsv_sug3=2&rsv_sug2=0&inputT=227&rsv_sug4=397", + expectedSearchCountEntry: "baidu:tagged-follow-on:monline_7_dg", + }, + { + title: "Baidu organic", + trackingUrl: + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&tn=baidu&bar=&wd=test&rn=&oq&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn", + expectedSearchCountEntry: "baidu:organic:other", + }, + { + title: "Baidu organic no code", + trackingUrl: + "https://www.baidu.com/s?ie=utf-8&f=8&rsv_bp=1&rsv_idx=1&ch=&bar=&wd=test&rn=&oq&rsv_pq=RSV_PQ_VALUE&rsv_t=RSV_T_VALUE&rqlang=cn", + expectedSearchCountEntry: "baidu:organic:none", + }, + { + title: "Ecosia search access point", + trackingUrl: "https://www.ecosia.org/search?tt=mzl&q=foo", + expectedSearchCountEntry: "ecosia:tagged:mzl", + expectedAdKey: "ecosia:tagged", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: [], + }, + { + title: "Ecosia organic", + trackingUrl: "https://www.ecosia.org/search?method=index&q=foo", + expectedSearchCountEntry: "ecosia:organic:none", + expectedAdKey: "ecosia:organic", + adUrls: ["https://www.bing.com/aclick?ld=foo"], + nonAdUrls: [], + }, +]; + +/** + * This function is primarily for testing the Ad URL regexps that are triggered + * when a URL is clicked on. These regexps are also used for the `with_ads` + * probe. However, we test the ad_clicks route as that is easier to hit. + * + * @param {string} serpUrl + * The url to simulate where the page the click came from. + * @param {string} adUrl + * The ad url to simulate being clicked. + * @param {string} [expectedAdKey] + * The expected key to be logged for the scalar. Omit if no scalar should be + * logged. + */ +async function testAdUrlClicked(serpUrl, adUrl, expectedAdKey) { + info(`Testing Ad URL: ${adUrl}`); + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(adUrl), + triggeringPrincipal: Services.scriptSecurityManager.createContentPrincipal( + NetUtil.newURI(serpUrl), + {} + ), + loadUsingSystemPrincipal: true, + }); + SearchSERPTelemetry._contentHandler.observeActivity( + channel, + Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION, + Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE + ); + // Since the content handler takes a moment to allow the channel information + // to settle down, wait the same amount of time here. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + if (!expectedAdKey) { + Assert.ok( + !("browser.search.adclicks.unknown" in scalars), + "Should not have recorded an ad click" + ); + } else { + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.adclicks.unknown", + expectedAdKey, + 1 + ); + } +} + +do_get_profile(); + +add_task(async function setup() { + Services.prefs.setBoolPref(SearchUtils.BROWSER_SEARCH_PREF + "log", true); + await SearchSERPTelemetry.init(); + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); +}); + +add_task(async function test_parsing_search_urls() { + for (const test of TESTS) { + info(`Running ${test.title}`); + if (test.setUp) { + test.setUp(); + } + SearchSERPTelemetry.updateTrackingStatus( + { + getTabBrowser: () => {}, + }, + test.trackingUrl + ); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.unknown", + test.expectedSearchCountEntry, + 1 + ); + + if ("adUrls" in test) { + for (const adUrl of test.adUrls) { + await testAdUrlClicked(test.trackingUrl, adUrl, test.expectedAdKey); + } + for (const nonAdUrls of test.nonAdUrls) { + await testAdUrlClicked(test.trackingUrl, nonAdUrls); + } + } + + if (test.tearDown) { + test.tearDown(); + } + } +}); diff --git a/browser/components/search/test/unit/test_urlTelemetry_generic.js b/browser/components/search/test/unit/test_urlTelemetry_generic.js new file mode 100644 index 0000000000..610dd56e3a --- /dev/null +++ b/browser/components/search/test/unit/test_urlTelemetry_generic.js @@ -0,0 +1,323 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: /^https:\/\/www\.example\.com\/search/, + queryParamName: "q", + codeParamName: "abc", + taggedCodes: ["ff", "tb"], + expectedOrganicCodes: ["baz"], + organicCodes: ["foo"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/www\.example\.com\/ad2/], + shoppingTab: { + regexp: "&site=shop", + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, + { + telemetryId: "example2", + searchPageRegexp: /^https:\/\/www\.example2\.com\/search/, + queryParamName: "q", + codeParamName: "abc", + taggedCodes: ["ff", "tb"], + expectedOrganicCodes: ["baz"], + organicCodes: ["foo"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/www\.example\.com\/ad2/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +const TESTS = [ + { + title: "Tagged search", + trackingUrl: "https://www.example.com/search?q=test&abc=ff", + expectedSearchCountEntry: "example:tagged:ff", + expectedAdKey: "example:tagged", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Tagged search with shopping", + trackingUrl: "https://www.example.com/search?q=test&abc=ff&site=shop", + expectedSearchCountEntry: "example:tagged:ff", + expectedAdKey: "example:tagged", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + is_shopping_page: "true", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Tagged follow-on", + trackingUrl: "https://www.example.com/search?q=test&abc=tb&a=next", + expectedSearchCountEntry: "example:tagged-follow-on:tb", + expectedAdKey: "example:tagged-follow-on", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "true", + partner_code: "tb", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search matched code", + trackingUrl: "https://www.example.com/search?q=test&abc=foo", + expectedSearchCountEntry: "example:organic:foo", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "foo", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search non-matched code", + trackingUrl: "https://www.example.com/search?q=test&abc=ff123", + expectedSearchCountEntry: "example:organic:other", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "other", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search non-matched code 2", + trackingUrl: "https://www.example.com/search?q=test&abc=foo123", + expectedSearchCountEntry: "example:organic:other", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "other", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search expected organic matched code", + trackingUrl: "https://www.example.com/search?q=test&abc=baz", + expectedSearchCountEntry: "example:organic:none", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Organic search no codes", + trackingUrl: "https://www.example.com/search?q=test", + expectedSearchCountEntry: "example:organic:none", + expectedAdKey: "example:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example", + tagged: "false", + partner_code: "", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, + { + title: "Different engines using the same adUrl", + trackingUrl: "https://www.example2.com/search?q=test", + expectedSearchCountEntry: "example2:organic:none", + expectedAdKey: "example2:organic", + adUrls: ["https://www.example.com/ad2"], + nonAdUrls: ["https://www.example.com/ad3"], + impression: { + provider: "example2", + tagged: "false", + partner_code: "", + is_shopping_page: "false", + shopping_tab_displayed: "false", + source: "unknown", + }, + }, +]; + +/** + * This function is primarily for testing the Ad URL regexps that are triggered + * when a URL is clicked on. These regexps are also used for the `withads` + * probe. However, we test the adclicks route as that is easier to hit. + * + * @param {string} serpUrl + * The url to simulate where the page the click came from. + * @param {string} adUrl + * The ad url to simulate being clicked. + * @param {string} [expectedAdKey] + * The expected key to be logged for the scalar. Omit if no scalar should be + * logged. + */ +async function testAdUrlClicked(serpUrl, adUrl, expectedAdKey) { + info(`Testing Ad URL: ${adUrl}`); + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(adUrl), + triggeringPrincipal: Services.scriptSecurityManager.createContentPrincipal( + NetUtil.newURI(serpUrl), + {} + ), + loadUsingSystemPrincipal: true, + }); + SearchSERPTelemetry._contentHandler.observeActivity( + channel, + Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION, + Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE + ); + // Since the content handler takes a moment to allow the channel information + // to settle down, wait the same amount of time here. + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + if (!expectedAdKey) { + Assert.ok( + !("browser.search.adclicks.unknown" in scalars), + "Should not have recorded an ad click" + ); + } else { + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.adclicks.unknown", + expectedAdKey, + 1 + ); + } +} + +do_get_profile(); + +add_task(async function setup() { + Services.prefs.setBoolPref(SearchUtils.BROWSER_SEARCH_PREF + "log", true); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "serpEventTelemetry.enabled", + true + ); + Services.fog.initializeFOG(); + await SearchSERPTelemetry.init(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); +}); + +add_task(async function test_parsing_search_urls() { + for (const test of TESTS) { + info(`Running ${test.title}`); + if (test.setUp) { + test.setUp(); + } + let browser = { + getTabBrowser: () => {}, + }; + SearchSERPTelemetry.updateTrackingStatus(browser, test.trackingUrl); + SearchSERPTelemetry.reportPageImpression( + { + url: test.trackingUrl, + shoppingTabDisplayed: false, + }, + browser + ); + let scalars = TelemetryTestUtils.getProcessScalars("parent", true, true); + TelemetryTestUtils.assertKeyedScalar( + scalars, + "browser.search.content.unknown", + test.expectedSearchCountEntry, + 1 + ); + + if ("adUrls" in test) { + for (const adUrl of test.adUrls) { + await testAdUrlClicked(test.trackingUrl, adUrl, test.expectedAdKey); + } + for (const nonAdUrls of test.nonAdUrls) { + await testAdUrlClicked(test.trackingUrl, nonAdUrls); + } + } + + let recordedEvents = Glean.serp.impression.testGetValue(); + + Assert.equal( + recordedEvents.length, + 1, + "should only see one impression event" + ); + + // To allow deep equality. + test.impression.impression_id = recordedEvents[0].extra.impression_id; + Assert.deepEqual(recordedEvents[0].extra, test.impression); + + if (test.tearDown) { + test.tearDown(); + } + + // We need to clear Glean events so they don't accumulate for each iteration. + Services.fog.testResetFOG(); + } +}); diff --git a/browser/components/search/test/unit/xpcshell.ini b/browser/components/search/test/unit/xpcshell.ini new file mode 100644 index 0000000000..7feeb6d38c --- /dev/null +++ b/browser/components/search/test/unit/xpcshell.ini @@ -0,0 +1,9 @@ +[DEFAULT] +skip-if = toolkit == 'android' # bug 1730213 +firefox-appdir = browser + +[test_search_telemetry_config_validation.js] +support-files = + ../../schema/search-telemetry-schema.json +[test_urlTelemetry.js] +[test_urlTelemetry_generic.js] |