diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/search/test | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/search/test')
158 files changed, 25960 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/browser.toml b/browser/components/search/test/browser/browser.toml new file mode 100644 index 0000000000..028cc7e233 --- /dev/null +++ b/browser/components/search/test/browser/browser.toml @@ -0,0 +1,103 @@ +[DEFAULT] +support-files = [ + "mozsearch.sjs", + "test_search.html", + "426329.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_addKeywordSearch.js"] + +["browser_contentContextMenu.js"] +support-files = ["browser_contentContextMenu.xhtml"] + +["browser_contentSearch.js"] +support-files = [ + "contentSearchBadImage.xml", + "contentSearchSuggestions.sjs", + "contentSearchSuggestions.xml", + "testEngine_chromeicon.xml", +] + +["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_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_nimbus_reload.js"] + +["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", # Bug 1792718 + "os == 'linux' && asan", # Bug 1792718 + "debug", # Bug 1792718 + "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..093c793048 --- /dev/null +++ b/browser/components/search/test/browser/browser_426329.js @@ -0,0 +1,301 @@ +/* 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"]; +var preSelectedBrowser; +var preTabNo; + +async function prepareTest() { + preSelectedBrowser = gBrowser.selectedBrowser; + preTabNo = gBrowser.tabs.length; + + await SimpleTest.promiseFocus(); + + if (document.activeElement == searchBar) { + return; + } + + let focusPromise = BrowserTestUtils.waitForEvent(searchBar.textbox, "focus"); + gURLBar.focus(); + searchBar.focus(); + await focusPromise; +} + +add_setup(async function () { + await Services.search.init(); + + await gCUITestUtils.addSearchBar(); + + await SearchTestUtils.promiseNewSearchEngine({ + url: "http://mochi.test:8888/browser/browser/components/search/test/browser/426329.xml", + setAsDefault: true, + }); + + searchBar = BrowserSearch.searchBar; + searchBar.value = "test"; + searchButton = searchBar.querySelector(".search-go-button"); + + registerCleanupFunction(() => { + searchBar.value = ""; + while (gBrowser.tabs.length != 1) { + gBrowser.removeTab(gBrowser.tabs[0], { animate: false }); + } + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:blank", + { + triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal( + {} + ), + } + ); + gCUITestUtils.removeSearchBar(); + }); +}); + +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.startLoadingURIString( + 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(); + }, 2000); + 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" } + ); + Assert.greater( + 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") + ); + Assert.equal(count, 0, "History cleared"); +}); + +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_addKeywordSearch.js b/browser/components/search/test/browser/browser_addKeywordSearch.js new file mode 100644 index 0000000000..c9153f9974 --- /dev/null +++ b/browser/components/search/test/browser/browser_addKeywordSearch.js @@ -0,0 +1,115 @@ +var testData = [ + { desc: "No path", action: "https://example.com/", param: "q" }, + { + desc: "With path", + action: "https://example.com/new-path-here/", + param: "q", + }, + { desc: "No action", action: "", param: "q" }, + { + desc: "With Query String", + action: "https://example.com/search?oe=utf-8", + param: "q", + }, + { + desc: "With Unicode Query String", + action: "https://example.com/searching", + param: "q", + testHiddenUnicode: true, + }, +]; + +add_task(async function () { + const TEST_URL = + "https://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, testHiddenUnicode = false } 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, testHiddenUnicode }], + 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); + if (args.testHiddenUnicode) { + form.insertAdjacentHTML( + "beforeend", + `<input name="utf8✓" type="hidden" value="✓">` + ); + } + 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}` + ); + if (testHiddenUnicode) { + ok( + data.spec.includes(`utf8%E2%9C%93=%E2%9C%93`), + `Check the unicode param is correctly encoded` + ); + } + } else { + is( + data.spec, + url, + `Check expected url for field named ${param} and action ${action}` + ); + if (testHiddenUnicode) { + is( + data.postData, + `utf8%u2713%3D%u2713&q%3D%25s`, + `Check expected POST data for field named ${param} and action ${action}` + ); + } else { + 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_contentSearch.js b/browser/components/search/test/browser/browser_contentSearch.js new file mode 100644 index 0000000000..7b9328fb94 --- /dev/null +++ b/browser/components/search/test/browser/browser_contentSearch.js @@ -0,0 +1,516 @@ +/* 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/. */ + +ChromeUtils.defineESModuleGetters(this, { + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", +}); + +SearchTestUtils.init(this); + +const SERVICE_EVENT_TYPE = "ContentSearchService"; +const CLIENT_EVENT_TYPE = "ContentSearchClient"; + +var arrayBufferIconTested = false; +var plainURIIconTested = false; + +function sendEventToContent(browser, data) { + return SpecialPowers.spawn( + browser, + [CLIENT_EVENT_TYPE, data], + (eventName, eventData) => { + content.dispatchEvent( + new content.CustomEvent(eventName, { + detail: Cu.cloneInto(eventData, content), + }) + ); + } + ); +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.newtab.preload", false], + ["browser.search.separatePrivateDefault.ui.enabled", true], + ["browser.search.separatePrivateDefault", true], + ], + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/search/test/browser/testEngine.xml", + setAsDefault: true, + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: "chrome://mochitests/content/browser/browser/components/search/test/browser/testEngine_diacritics.xml", + setAsDefaultPrivate: true, + }); + + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine_chromeicon.xml", + }); +}); + +add_task(async function GetState() { + let { browser } = await addTab(); + let statePromise = await waitForTestMsg(browser, "State"); + sendEventToContent(browser, { + type: "GetState", + }); + let msg = await statePromise.donePromise; + checkMsg(msg, { + type: "State", + data: await currentStateObj(false), + }); + + ok(arrayBufferIconTested, "ArrayBuffer path for the iconData was tested"); + ok(plainURIIconTested, "Plain URI path for the iconData was tested"); +}); + +add_task(async function SetDefaultEngine() { + let { browser } = await addTab(); + let newDefaultEngine = await Services.search.getEngineByName("FooChromeIcon"); + let oldDefaultEngine = await Services.search.getDefault(); + let searchPromise = await waitForTestMsg(browser, "CurrentEngine"); + sendEventToContent(browser, { + type: "SetCurrentEngine", + data: newDefaultEngine.name, + }); + let deferredPromise = new Promise(resolve => { + Services.obs.addObserver(function obs(subj, topic, data) { + info("Test observed " + data); + if (data == "engine-default") { + ok(true, "Test observed engine-default"); + Services.obs.removeObserver(obs, "browser-search-engine-modified"); + resolve(); + } + }, "browser-search-engine-modified"); + }); + info("Waiting for test to observe engine-default..."); + await deferredPromise; + let msg = await searchPromise.donePromise; + checkMsg(msg, { + type: "CurrentEngine", + data: await constructEngineObj(newDefaultEngine), + }); + + let enginePromise = await waitForTestMsg(browser, "CurrentEngine"); + await Services.search.setDefault( + oldDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + msg = await enginePromise.donePromise; + checkMsg(msg, { + type: "CurrentEngine", + data: await constructEngineObj(oldDefaultEngine), + }); +}); + +// ContentSearchChild doesn't support setting the private engine at this time +// as it doesn't need to, so we just test updating the default here. +add_task(async function setDefaultEnginePrivate() { + const engine = await Services.search.getEngineByName("FooChromeIcon"); + const { browser } = await addTab(); + let enginePromise = await waitForTestMsg(browser, "CurrentPrivateEngine"); + await Services.search.setDefaultPrivate( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + let msg = await enginePromise.donePromise; + checkMsg(msg, { + type: "CurrentPrivateEngine", + data: await constructEngineObj(engine), + }); +}); + +add_task(async function modifyEngine() { + let { browser } = await addTab(); + let engine = await Services.search.getDefault(); + let oldAlias = engine.alias; + let statePromise = await waitForTestMsg(browser, "CurrentState"); + engine.alias = "ContentSearchTest"; + let msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(), + }); + statePromise = await waitForTestMsg(browser, "CurrentState"); + engine.alias = oldAlias; + msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(), + }); +}); + +add_task(async function test_hideEngine() { + let { browser } = await addTab(); + let engine = await Services.search.getEngineByName("Foo \u2661"); + let statePromise = await waitForTestMsg(browser, "CurrentState"); + engine.hideOneOffButton = true; + let msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(undefined, "Foo \u2661"), + }); + statePromise = await waitForTestMsg(browser, "CurrentState"); + engine.hideOneOffButton = false; + msg = await statePromise.donePromise; + checkMsg(msg, { + type: "CurrentState", + data: await currentStateObj(), + }); +}); + +add_task(async function search() { + let { browser } = await addTab(); + let engine = await Services.search.getDefault(); + let data = { + engineName: engine.name, + searchString: "ContentSearchTest", + healthReportKey: "ContentSearchTest", + searchPurpose: "ContentSearchTest", + }; + let submissionURL = engine.getSubmission(data.searchString, "", data.whence) + .uri.spec; + + await performSearch(browser, data, submissionURL); +}); + +add_task(async function searchInBackgroundTab() { + // This test is like search(), but it opens a new tab after starting a search + // in another. In other words, it performs a search in a background tab. The + // search page should be loaded in the same tab that performed the search, in + // the background tab. + let { browser } = await addTab(); + let engine = await Services.search.getDefault(); + let data = { + engineName: engine.name, + searchString: "ContentSearchTest", + healthReportKey: "ContentSearchTest", + searchPurpose: "ContentSearchTest", + }; + let submissionURL = engine.getSubmission(data.searchString, "", data.whence) + .uri.spec; + + let searchPromise = performSearch(browser, data, submissionURL); + let newTab = BrowserTestUtils.addTab(gBrowser); + gBrowser.selectedTab = newTab; + registerCleanupFunction(() => gBrowser.removeTab(newTab)); + + await searchPromise; +}); + +add_task(async function badImage() { + let { browser } = await addTab(); + // If the bad image URI caused an exception to be thrown within ContentSearch, + // then we'll hang waiting for the CurrentState responses triggered by the new + // engine. That's what we're testing, and obviously it shouldn't happen. + let [engine, currentStateMsg] = await waitForNewEngine( + browser, + "contentSearchBadImage.xml" + ); + let expectedCurrentState = await currentStateObj(); + let expectedEngine = expectedCurrentState.engines.find( + e => e.name == engine.name + ); + ok(!!expectedEngine, "Sanity check: engine should be in expected state"); + Assert.strictEqual( + expectedEngine.iconData, + "chrome://browser/skin/search-engine-placeholder.png", + "Sanity check: icon of engine in expected state should be the placeholder: " + + expectedEngine.iconData + ); + checkMsg(currentStateMsg, { + type: "CurrentState", + data: expectedCurrentState, + }); + // Removing the engine triggers a final CurrentState message. Wait for it so + // it doesn't trip up subsequent tests. + let statePromise = await waitForTestMsg(browser, "CurrentState"); + await Services.search.removeEngine(engine); + await statePromise.donePromise; +}); + +add_task( + async function GetSuggestions_AddFormHistoryEntry_RemoveFormHistoryEntry() { + let { browser } = await addTab(); + + // Add the test engine that provides suggestions. + let [engine] = await waitForNewEngine( + browser, + "contentSearchSuggestions.xml" + ); + + let searchStr = "browser_contentSearch.js-suggestions-"; + + // Add a form history suggestion and wait for Satchel to notify about it. + sendEventToContent(browser, { + type: "AddFormHistoryEntry", + data: { + value: searchStr + "form", + engineName: engine.name, + }, + }); + await 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"); + }); + + // Send GetSuggestions using the test engine. Its suggestions should appear + // in the remote suggestions in the Suggestions response below. + let suggestionsPromise = await waitForTestMsg(browser, "Suggestions"); + sendEventToContent(browser, { + type: "GetSuggestions", + data: { + engineName: engine.name, + searchString: searchStr, + }, + }); + + // Check the Suggestions response. + let msg = await suggestionsPromise.donePromise; + checkMsg(msg, { + type: "Suggestions", + data: { + engineName: engine.name, + searchString: searchStr, + formHistory: [searchStr + "form"], + remote: [searchStr + "foo", searchStr + "bar"], + }, + }); + + // Delete the form history suggestion and wait for Satchel to notify about it. + sendEventToContent(browser, { + type: "RemoveFormHistoryEntry", + data: searchStr + "form", + }); + + await 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"); + }); + + // Send GetSuggestions again. + suggestionsPromise = await waitForTestMsg(browser, "Suggestions"); + sendEventToContent(browser, { + type: "GetSuggestions", + data: { + engineName: engine.name, + searchString: searchStr, + }, + }); + + // The formHistory suggestions in the Suggestions response should be empty. + msg = await suggestionsPromise.donePromise; + checkMsg(msg, { + type: "Suggestions", + data: { + engineName: engine.name, + searchString: searchStr, + formHistory: [], + remote: [searchStr + "foo", searchStr + "bar"], + }, + }); + + // Finally, clean up by removing the test engine. + let statePromise = await waitForTestMsg(browser, "CurrentState"); + await Services.search.removeEngine(engine); + await statePromise.donePromise; + } +); + +async function performSearch(browser, data, expectedURL) { + let stoppedPromise = BrowserTestUtils.browserStopped(browser, expectedURL); + sendEventToContent(browser, { + type: "Search", + data, + expectedURL, + }); + + await stoppedPromise; + // BrowserTestUtils.browserStopped should ensure this, but let's + // be absolutely sure. + Assert.equal( + browser.currentURI.spec, + expectedURL, + "Correct search page loaded" + ); +} + +function buffersEqual(actualArrayBuffer, expectedArrayBuffer) { + let expectedView = new Int8Array(expectedArrayBuffer); + let actualView = new Int8Array(actualArrayBuffer); + for (let i = 0; i < expectedView.length; i++) { + if (actualView[i] != expectedView[i]) { + return false; + } + } + return true; +} + +function arrayBufferEqual(actualArrayBuffer, expectedArrayBuffer) { + ok(actualArrayBuffer instanceof ArrayBuffer, "Actual value is ArrayBuffer."); + ok( + expectedArrayBuffer instanceof ArrayBuffer, + "Expected value is ArrayBuffer." + ); + Assert.equal( + actualArrayBuffer.byteLength, + expectedArrayBuffer.byteLength, + "Array buffers have the same length." + ); + ok( + buffersEqual(actualArrayBuffer, expectedArrayBuffer), + "Buffers are equal." + ); +} + +function checkArrayBuffers(actual, expected) { + if (actual instanceof ArrayBuffer) { + arrayBufferEqual(actual, expected); + } + if (typeof actual == "object") { + for (let i in actual) { + checkArrayBuffers(actual[i], expected[i]); + } + } +} + +function checkMsg(actualMsg, expectedMsgData) { + SimpleTest.isDeeply(actualMsg, expectedMsgData, "Checking message"); + + // Engines contain ArrayBuffers which we have to compare byte by byte and + // not as Objects (like SimpleTest.isDeeply does). + checkArrayBuffers(actualMsg, expectedMsgData); +} + +async function waitForTestMsg(browser, type) { + // We call SpecialPowers.spawn twice because we must let the first one + // complete so that the listener is added before we return from this function. + // In the second one, we wait for the signal that the expected message has + // been received. + await SpecialPowers.spawn( + browser, + [SERVICE_EVENT_TYPE, type], + async (childEvent, childType) => { + function listener(event) { + if (event.detail.type != childType) { + return; + } + + content.eventDetails = event.detail; + content.removeEventListener(childEvent, listener, true); + } + // Ensure any previous details are cleared, so that we don't + // get the wrong ones by mistake. + content.eventDetails = undefined; + content.addEventListener(childEvent, listener, true); + } + ); + + let donePromise = SpecialPowers.spawn(browser, [type], async childType => { + await ContentTaskUtils.waitForCondition(() => { + return !!content.eventDetails; + }, "Expected " + childType + " event"); + return content.eventDetails; + }); + + return { donePromise }; +} + +async function waitForNewEngine(browser, basename) { + info("Waiting for engine to be added: " + basename); + + // Wait for the search events triggered by adding the new engine. + // There are two events triggerd by engine-added and engine-loaded + let statePromise = await waitForTestMsg(browser, "CurrentState"); + + let engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + basename, + }); + return [engine, await statePromise.donePromise]; +} + +async function addTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab" + ); + registerCleanupFunction(() => gBrowser.removeTab(tab)); + + return { browser: tab.linkedBrowser }; +} + +var currentStateObj = async function (isPrivateWindowValue, hiddenEngine = "") { + let state = { + engines: [], + currentEngine: await constructEngineObj(await Services.search.getDefault()), + currentPrivateEngine: await constructEngineObj( + await Services.search.getDefaultPrivate() + ), + }; + for (let engine of await Services.search.getVisibleEngines()) { + let uri = engine.getIconURL(16); + state.engines.push({ + name: engine.name, + iconData: await iconDataFromURI(uri), + hidden: engine.name == hiddenEngine, + isAppProvided: engine.isAppProvided, + }); + } + if (typeof isPrivateWindowValue == "boolean") { + state.isInPrivateBrowsingMode = isPrivateWindowValue; + state.isAboutPrivateBrowsing = isPrivateWindowValue; + } + return state; +}; + +async function constructEngineObj(engine) { + let uriFavicon = engine.getIconURL(16); + return { + name: engine.name, + iconData: await iconDataFromURI(uriFavicon), + isAppProvided: engine.isAppProvided, + }; +} + +function iconDataFromURI(uri) { + if (!uri) { + return Promise.resolve( + "chrome://browser/skin/search-engine-placeholder.png" + ); + } + + if (!uri.startsWith("data:")) { + plainURIIconTested = true; + return Promise.resolve(uri); + } + + return new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", uri, true); + xhr.responseType = "arraybuffer"; + xhr.onerror = () => { + resolve("chrome://browser/skin/search-engine-placeholder.png"); + }; + xhr.onload = () => { + arrayBufferIconTested = true; + resolve(xhr.response); + }; + try { + xhr.send(); + } catch (err) { + resolve("chrome://browser/skin/search-engine-placeholder.png"); + } + }); +} 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..47114fa6da --- /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.getIconURL(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.getIconURL(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..cce3b3ce1f --- /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.startLoadingURIString(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_diacritics.js b/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js new file mode 100644 index 0000000000..f8d6d34d7e --- /dev/null +++ b/browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js @@ -0,0 +1,75 @@ +/* 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; +let engine; + +add_setup(async function () { + let searchbar = await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); + searchIcon = searchbar.querySelector(".searchbar-search-button"); + + let defaultEngine = await Services.search.getDefault(); + engine = await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + "testEngine_diacritics.xml", + }); + registerCleanupFunction(async () => { + await Services.search.setDefault( + defaultEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + engine.hideOneOffButton = false; + }); +}); + +add_task(async function test_hidden() { + engine.hideOneOffButton = true; + + 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() { + engine.hideOneOffButton = false; + + 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..40aa4aa27d --- /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, "https://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..9f05e948ed --- /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.getIconURL(); + if (isPrivateWindow) { + expectedName = originalPrivateEngine.name; + expectedImage = originalPrivateEngine.getIconURL(); + } + + 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..f0617ea7d4 --- /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_setup(async function () { + 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..98adedcee5 --- /dev/null +++ b/browser/components/search/test/browser/browser_rich_suggestions.js @@ -0,0 +1,125 @@ +/* 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], + // 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.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 scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar(scalars, "urlbar.engagement", 1); + + await SpecialPowers.popPrefEnv(); +} + +add_task(async function test_richsuggestion_deduplication() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.richSuggestions.featureGate", true]], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "test0", + waitForFocus: SimpleTest.waitForFocus, + }); + + let { result: heuristicResult } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 0 + ); + let { result: richResult } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 1 + ); + + // The Rich Suggestion that points to the same query as the Hueristic result + // should not be deduplicated. + Assert.equal(heuristicResult.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(heuristicResult.providerName, "HeuristicFallback"); + Assert.equal(richResult.type, UrlbarUtils.RESULT_TYPE.SEARCH); + Assert.equal(richResult.providerName, "SearchSuggestions"); + Assert.equal( + heuristicResult.payload.query, + richResult.payload.lowerCaseSuggestion + ); + + await UrlbarTestUtils.promisePopupClose(window); +}); 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..15a30583bf --- /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/s?tag=admarketus-20&ref=pd_sl_a71c226e8a96bfdb7ae5bc6d1f30e9e88d9e4e3436d7bfb941a95d0a&mfadid=adm&k=foo", + 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_setup(async function () { + 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.startLoadingURIString(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..92f7a252f8 --- /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 || "https://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 == "https://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 = "https://first.mozilla.com/search.xml"; + link.type = "application/opensearchdescription+xml"; + link.title = "Test Engine"; + let link2 = link.cloneNode(false); + link2.href = "https://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, + "https://first.mozilla.com/search.xml", + "first engine wins" + ); + browser.engines = null; +} 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_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..ee292db1b5 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js @@ -0,0 +1,663 @@ +// 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(); + Assert.greaterOrEqual( + 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. + ok( + !Services.focus.focusedElement.classList.contains("searchbar-textbox"), + "the search input in the search bar should 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. + ok( + !Services.focus.focusedElement.classList.contains("searchbar-textbox"), + "the search input in the search bar should no longer be focused" + ); + + // Return the focus to the search bar + EventUtils.synthesizeKey("KEY_Tab"); + ok( + Services.focus.focusedElement.classList.contains("searchbar-textbox"), + "the search bar should be focused" + ); + + // ... and confirm the input value was autoselected and is replaced. + EventUtils.synthesizeKey("fo"); + is( + Services.focus.focusedElement.value, + "fo", + "when the search bar was focused, the value should be autoselected" + ); + // Return to the expected value + EventUtils.synthesizeKey("o"); +}); + +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..2653e65e8d --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_openpopup.js @@ -0,0 +1,812 @@ +// 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"]) { + 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"); + is( + searchIcon.getAttribute("aria-expanded"), + "false", + "The search icon is not expanded by default" + ); + EventUtils.synthesizeMouseAtCenter(searchIcon, {}); + await promise; + is( + searchPopup.getAttribute("showonlysettings"), + "true", + "Should only show the settings" + ); + is( + searchIcon.getAttribute("aria-expanded"), + "true", + "The search icon is now expanded" + ); + is(textbox.mController.searchString, "", "Should be an empty search string"); + + let image = searchPopup.querySelector(".searchbar-engine-image"); + Assert.equal( + image.src, + engine.getIconURL(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" + ); + is( + searchIcon.getAttribute("aria-expanded"), + "false", + "The search icon should not be expanded" + ); + + // 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 + ); + + engines.forEach(e => { + e.hideOneOffButton = true; + }); + + 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.isHidden(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; + + engines.forEach(e => { + e.hideOneOffButton = false; + }); + 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..a4509cbd90 --- /dev/null +++ b/browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js @@ -0,0 +1,453 @@ +// 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(); + Assert.greaterOrEqual( + 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..e084850803 --- /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_setup(async function () { + 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..74d0b944d5 --- /dev/null +++ b/browser/components/search/test/browser/browser_trending_suggestions.js @@ -0,0 +1,240 @@ +/* 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], + ["browser.urlbar.suggest.trending", true], + ], + }); + + SearchTestUtils.useMockIdleService(); + await SearchTestUtils.updateRemoteSettingsConfig(CONFIG_DEFAULT); + Services.telemetry.clearScalars(); + + 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); +}); + +add_task(async function test_block_trending() { + Services.telemetry.clearScalars(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.trending.featureGate", true], + ["browser.urlbar.trending.requireSearchMode", false], + ], + }); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "", + waitForFocus: SimpleTest.waitForFocus, + }); + + Assert.equal(UrlbarTestUtils.getResultCount(window), 2); + let { result: trendingResult } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 0 + ); + Assert.equal(trendingResult.payload.trending, true); + + await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", { + resultIndex: 0, + }); + + await BrowserTestUtils.waitForCondition( + () => UrlbarTestUtils.getResultCount(window) == 1 + ); + let { result: heuristicResult } = await UrlbarTestUtils.getDetailsOfResultAt( + window, + 0 + ); + Assert.notEqual(heuristicResult.payload.trending, true); + + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "urlbar.trending.block", + 1 + ); + + await UrlbarTestUtils.promisePopupClose(window, () => { + EventUtils.synthesizeKey("KEY_Escape"); + }); + await SpecialPowers.popPrefEnv(); +}); + +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/contentSearchBadImage.xml b/browser/components/search/test/browser/contentSearchBadImage.xml new file mode 100644 index 0000000000..6e4cb60a58 --- /dev/null +++ b/browser/components/search/test/browser/contentSearchBadImage.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_ContentSearch contentSearchBadImage.xml</ShortName> +<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchBadImage" rel="searchform"/> +<Image width="16" height="16">data:image/x-icon;base64,notbase64</Image> +</SearchPlugin> diff --git a/browser/components/search/test/browser/contentSearchSuggestions.sjs b/browser/components/search/test/browser/contentSearchSuggestions.sjs new file mode 100644 index 0000000000..1978b4f665 --- /dev/null +++ b/browser/components/search/test/browser/contentSearchSuggestions.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/contentSearchSuggestions.xml b/browser/components/search/test/browser/contentSearchSuggestions.xml new file mode 100644 index 0000000000..ca368c34f8 --- /dev/null +++ b/browser/components/search/test/browser/contentSearchSuggestions.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_ContentSearch contentSearchSuggestions.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/contentSearchSuggestions.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://browser-ContentSearch.com/contentSearchSuggestions" rel="searchform"/> +</SearchPlugin> 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.toml b/browser/components/search/test/browser/google_codes/browser.toml new file mode 100644 index 0000000000..626dc57cac --- /dev/null +++ b/browser/components/search/test/browser/google_codes/browser.toml @@ -0,0 +1,4 @@ +[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..7a45a9f4f5 --- /dev/null +++ b/browser/components/search/test/browser/head.js @@ -0,0 +1,133 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + 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); + +/** + * 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; + } + ); +} + +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" }); +} + +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/search-engines/basic/manifest.json b/browser/components/search/test/browser/search-engines/basic/manifest.json new file mode 100644 index 0000000000..63ec838bee --- /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://example.com/browser/browser/components/search/test/browser/trendingSuggestionEngine.sjs?richsuggestions=true&query={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..1da20124a4 --- /dev/null +++ b/browser/components/search/test/browser/searchSuggestionEngine.sjs @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +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/telemetry/browser.toml b/browser/components/search/test/browser/telemetry/browser.toml new file mode 100644 index 0000000000..49d8f256aa --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser.toml @@ -0,0 +1,197 @@ +[DEFAULT] +tags = "search-telemetry" +support-files = ["head.js", "head-spa.js"] +prefs = ["browser.search.log=true"] + +["browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js"] +support-files = [ + "domain_category_mappings.json", + "searchTelemetryDomainCategorizationReporting.html", +] + +["browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js"] +support-files = ["searchTelemetryAd.html"] + +["browser_search_telemetry_abandonment.js"] +support-files = [ + "searchTelemetry.html", + "searchTelemetryAd.html", + "searchTelemetryAd_components_text.html", +] + +["browser_search_telemetry_aboutHome.js"] + +["browser_search_telemetry_adImpression_component.js"] +support-files = [ + "searchTelemetryAd_components_carousel.html", + "searchTelemetryAd_components_carousel_below_the_fold.html", + "searchTelemetryAd_components_carousel_doubled.html", + "searchTelemetryAd_components_carousel_first_element_non_visible.html", + "searchTelemetryAd_components_carousel_hidden.html", + "searchTelemetryAd_components_carousel_outer_container.html", + "searchTelemetryAd_components_text.html", + "searchTelemetryAd_components_visibility.html", + "serp.css", +] + +["browser_search_telemetry_categorization_timing.js"] + +["browser_search_telemetry_content.js"] + +["browser_search_telemetry_domain_categorization_ad_values.js"] +support-files = ["searchTelemetryDomainCategorizationReporting.html"] + +["browser_search_telemetry_domain_categorization_download_timer.js"] +support-files = ["domain_category_mappings.json"] + +["browser_search_telemetry_domain_categorization_extraction.js"] +support-files = ["searchTelemetryDomainExtraction.html"] + +["browser_search_telemetry_domain_categorization_region.js"] +support-files = ["searchTelemetryDomainCategorizationReporting.html"] + +["browser_search_telemetry_domain_categorization_reporting.js"] +support-files = [ + "searchTelemetryDomainCategorizationReporting.html", + "searchTelemetryDomainCategorizationCapProcessedDomains.html", +] + +["browser_search_telemetry_domain_categorization_reporting_timer.js"] +support-files = ["searchTelemetryDomainCategorizationReporting.html"] + +["browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js"] +support-files = ["searchTelemetryDomainCategorizationReporting.html"] + +["browser_search_telemetry_engagement_cached.js"] +support-files = [ + "cacheable.html", + "cacheable.html^headers^", + "searchTelemetryAd_components_text.html", + "serp.css", +] + +["browser_search_telemetry_engagement_cached_serp.js"] +support-files = [ + "searchTelemetryAd_searchbox.html", + "searchTelemetryAd_searchbox.html^headers^", +] + +["browser_search_telemetry_engagement_content.js"] +support-files = [ + "searchTelemetryAd_searchbox_with_content.html", + "searchTelemetryAd_searchbox_with_content.html^headers^", + "searchTelemetryAd_searchbox_with_content_redirect.html", + "searchTelemetryAd_searchbox_with_content_redirect.html^headers^", + "serp.css", +] + +["browser_search_telemetry_engagement_multiple_tabs.js"] +support-files = [ + "searchTelemetryAd_searchbox_with_content.html", + "searchTelemetryAd_searchbox_with_content.html^headers^", +] + +["browser_search_telemetry_engagement_non_ad.js"] +support-files = [ + "searchTelemetryAd_searchbox_with_content.html", + "searchTelemetryAd_searchbox_with_content.html^headers^", + "serp.css", +] + +["browser_search_telemetry_engagement_query_params.js"] +support-files = [ + "searchTelemetryAd_components_query_parameters.html", + "serp.css", +] + +["browser_search_telemetry_engagement_redirect.js"] +support-files = [ + "redirect_ad.sjs", + "redirect_final.sjs", + "redirect_once.sjs", + "redirect_thrice.sjs", + "redirect_twice.sjs", + "searchTelemetryAd_components_text.html", + "searchTelemetryAd_nonAdsLink_redirect.html", + "searchTelemetryAd_nonAdsLink_redirect.html^headers^", + "searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html", + "searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^", + "serp.css", +] + +["browser_search_telemetry_engagement_target.js"] +support-files = [ + "searchTelemetryAd_components_text.html", + "searchTelemetryAd_searchbox.html", + "searchTelemetryAd_searchbox.html^headers^", + "serp.css", +] + +["browser_search_telemetry_new_window.js"] +support-files = ["searchTelemetry.html", "searchTelemetryAd.html"] + +["browser_search_telemetry_private.js"] +support-files = ["searchTelemetry.html", "searchTelemetryAd.html", "serp.css"] + +["browser_search_telemetry_remote_settings_sync.js"] +support-files = ["searchTelemetryAd.html", "serp.css"] + +["browser_search_telemetry_searchbar.js"] +https_first_disabled = true +support-files = [ + "telemetrySearchSuggestions.sjs", + "telemetrySearchSuggestions.xml", +] + +["browser_search_telemetry_shopping.js"] +support-files = ["searchTelemetryAd_shopping.html"] + +["browser_search_telemetry_sources.js"] +support-files = ["searchTelemetry.html", "searchTelemetryAd.html"] + +["browser_search_telemetry_sources_about.js"] +support-files = ["searchTelemetry.html", "searchTelemetryAd.html"] + +["browser_search_telemetry_sources_ads.js"] +support-files = ["searchTelemetry.html", "searchTelemetryAd.html"] + +["browser_search_telemetry_sources_ads_clicks.js"] +support-files = ["searchTelemetryAd.html"] + +["browser_search_telemetry_sources_ads_data_attributes.js"] +support-files = [ + "searchTelemetryAd_dataAttributes.html", + "searchTelemetryAd_dataAttributes_href.html", + "searchTelemetryAd_dataAttributes_none.html", +] + +["browser_search_telemetry_sources_ads_load_events.js"] +support-files = [ + "slow_loading_page_with_ads_on_load_event.html", + "slow_loading_page_with_ads.html", + "slow_loading_page_with_ads.sjs", +] + +["browser_search_telemetry_sources_in_content.js"] +support-files = ["searchTelemetryAd_searchbox_with_content.html"] + +["browser_search_telemetry_sources_navigation.js"] +support-files = ["searchTelemetry.html", "searchTelemetryAd.html"] + +["browser_search_telemetry_sources_webextension.js"] +support-files = ["searchTelemetry.html", "searchTelemetryAd.html"] + +["browser_search_telemetry_spa_in_content.js"] +support-files = ["searchTelemetrySinglePageApp.html"] +skip-if = [ + "(os == 'linux') && tsan && verify", +] # Can fail on ad_count visibility + +["browser_search_telemetry_spa_multi_provider.js"] +support-files = ["searchTelemetrySinglePageApp.html"] + +["browser_search_telemetry_spa_multi_tab.js"] +support-files = ["searchTelemetrySinglePageApp.html"] + +["browser_search_telemetry_spa_single_tab.js"] +support-files = ["searchTelemetrySinglePageApp.html"] diff --git a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js new file mode 100644 index 0000000000..ed71a7c5ed --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js @@ -0,0 +1,186 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test to verify we can toggle the Glean SERP event telemetry for SERP +// categorization feature via a Nimbus variable. + +const lazy = {}; +const TELEMETRY_PREF = + "browser.search.serpEventTelemetryCategorization.enabled"; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serpEventsCategorizationEnabled", + TELEMETRY_PREF, + false +); + +// This is required to trigger and properly categorize a SERP. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [/^https:\/\/example.com/], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + await insertRecordIntoCollectionAndSync(); + // If the categorization preference is enabled, we should also wait for the + // sync event to update the domain to categories map. + if (lazy.serpEventsCategorizationEnabled) { + await waitForDomainToCategoriesUpdate(); + } + + registerCleanupFunction(async () => { + Services.telemetry.canRecordExtended = oldCanRecord; + await SpecialPowers.popPrefEnv(); + resetTelemetry(); + }); +}); + +add_task(async function test_enable_experiment_when_pref_is_not_enabled() { + let prefBranch = Services.prefs.getDefaultBranch(""); + let originalPrefValue = prefBranch.getBoolPref(TELEMETRY_PREF); + + // Ensure the build being tested has the preference value as false. + // Changing the preference in the test must be done on the default branch + // because in the telemetry code, we're referencing the preference directly + // instead of through NimbusFeatures. Enrolling in an experiment will change + // the default branch, and not overwrite the user branch. + prefBranch.setBoolPref(TELEMETRY_PREF, false); + + Assert.equal( + lazy.serpEventsCategorizationEnabled, + false, + "serpEventsCategorizationEnabled should be false when not enrolled in experiment and the default value is false." + ); + + await lazy.ExperimentAPI.ready(); + + info("Enroll in experiment."); + let updateComplete = waitForDomainToCategoriesUpdate(); + + let doExperimentCleanup = await lazy.ExperimentFakes.enrollWithFeatureConfig( + { + featureId: NimbusFeatures.search.featureId, + value: { + serpEventTelemetryCategorizationEnabled: true, + }, + }, + { isRollout: true } + ); + + Assert.equal( + lazy.serpEventsCategorizationEnabled, + true, + "serpEventsCategorizationEnabled should be true when enrolled in experiment." + ); + + await updateComplete; + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); + resetTelemetry(); + + info("End experiment."); + await doExperimentCleanup(); + + Assert.equal( + lazy.serpEventsCategorizationEnabled, + false, + "serpEventsCategorizationEnabled should be false after experiment." + ); + + Assert.ok( + lazy.SearchSERPDomainToCategoriesMap.empty, + "Domain to categories map should be empty." + ); + + info("Load a sample SERP with organic results."); + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + // Wait an arbitrary amount for a possible categorization. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1500)); + BrowserTestUtils.removeTab(tab); + + assertCategorizationValues([]); + + // Clean up. + prefBranch.setBoolPref(TELEMETRY_PREF, originalPrefValue); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js new file mode 100644 index 0000000000..096178499b --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test to verify we can toggle the Glean SERP event telemetry feature via a +// Nimbus variable. + +const lazy = {}; + +const TELEMETRY_PREF = "browser.search.serpEventTelemetry.enabled"; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serpEventsEnabled", + TELEMETRY_PREF, + false +); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +async function verifyEventsRecorded() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ); + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }, + ]); +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + await SpecialPowers.popPrefEnv(); + resetTelemetry(); + }); +}); + +add_task(async function test_enable_experiment_when_pref_is_not_enabled() { + let prefBranch = Services.prefs.getDefaultBranch(""); + let originalPrefValue = prefBranch.getBoolPref(TELEMETRY_PREF); + + // Ensure the build being tested has the preference value as false. + // Changing the preference in the test must be done on the default branch + // because in the telemetry code, we're referencing the preference directly + // instead of through NimbusFeatures. Enrolling in an experiment will change + // the default branch, and not overwrite the user branch. + prefBranch.setBoolPref(TELEMETRY_PREF, false); + + Assert.equal( + lazy.serpEventsEnabled, + false, + "serpEventsEnabled should be false when not enrolled in experiment." + ); + + await lazy.ExperimentAPI.ready(); + + let doExperimentCleanup = await lazy.ExperimentFakes.enrollWithFeatureConfig( + { + featureId: NimbusFeatures.search.featureId, + value: { + serpEventTelemetryEnabled: true, + }, + }, + { isRollout: true } + ); + + Assert.equal( + lazy.serpEventsEnabled, + true, + "serpEventsEnabled should be true when enrolled in experiment." + ); + + // To ensure Nimbus set "browser.search.serpEventTelemetry.enabled" to true, + // we test that an impression, ad_impression and abandonment event are + // recorded correctly. + await verifyEventsRecorded(); + + await doExperimentCleanup(); + + Assert.equal( + lazy.serpEventsEnabled, + false, + "serpEventsEnabled should be false after experiment." + ); + + // Clean up. + prefBranch.setBoolPref(TELEMETRY_PREF, originalPrefValue); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js new file mode 100644 index 0000000000..0c1d8b8234 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js @@ -0,0 +1,294 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests for the Glean SERP abandonment event + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_tab_close() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetry.html") + ); + + await waitForPageWithAdImpressions(); + + BrowserTestUtils.removeTab(tab); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }, + ]); +}); + +add_task(async function test_window_close() { + resetTelemetry(); + + let serpUrl = getSERPUrl("searchTelemetry.html"); + let otherWindow = await BrowserTestUtils.openNewBrowserWindow(); + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + otherWindow.gBrowser, + false, + serpUrl + ); + BrowserTestUtils.startLoadingURIString(otherWindow.gBrowser, serpUrl); + await browserLoadedPromise; + await waitForPageWithAdImpressions(); + + await BrowserTestUtils.closeWindow(otherWindow); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.WINDOW_CLOSE, + }, + }, + ]); +}); + +add_task(async function test_navigation_via_urlbar() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetry.html") + ); + await waitForPageWithAdImpressions(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser, + false, + "https://www.example.com/" + ); + BrowserTestUtils.startLoadingURIString(gBrowser, "https://www.example.com"); + await browserLoadedPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_navigation_via_back_button() { + resetTelemetry(); + + let exampleUrl = "https://example.com/"; + let serpUrl = getSERPUrl("searchTelemetry.html"); + await BrowserTestUtils.withNewTab(exampleUrl, async browser => { + info("example.com is now loaded."); + + let pageLoadPromise = BrowserTestUtils.browserLoaded( + browser, + false, + serpUrl + ); + BrowserTestUtils.startLoadingURIString(browser, serpUrl); + await pageLoadPromise; + info("Serp is now loaded."); + + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + browser, + "pageshow" + ); + browser.goBack(); + await pageShowPromise; + + info("Previous page (example.com) is now loaded after back navigation."); + }); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + ]); +}); + +add_task(async function test_click_ad() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ); + + await TestUtils.waitForCondition(() => { + let adImpressions = Glean.serp.adImpression.testGetValue() ?? []; + return adImpressions.length; + }, "Should have received an ad impression."); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a", + {}, + gBrowser.selectedBrowser + ); + await browserLoadedPromise; + + Assert.equal( + !!Glean.serp.abandonment.testGetValue(), + false, + "Should not have any abandonment events." + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_click_non_ad() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_components_text.html") + ); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + Assert.equal( + !!Glean.serp.abandonment.testGetValue(), + false, + "Should not have any abandonment events." + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_without_components() { + // Mock a provider that doesn't have components. + let providerInfo = [ + { + ...TEST_PROVIDER_INFO[0], + components: [], + }, + ]; + SearchSERPTelemetry.overrideSearchTelemetryForTests(providerInfo); + await waitForIdle(); + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ); + + // We shouldn't expect a SERP impression, so instead wait roughly + // around how long it would usually take to receive an impression following + // a page load. + await promiseWaitForAdLinkCheck(); + Assert.equal( + !!Glean.serp.impression.testGetValue(), + false, + "Should not have any impression events." + ); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + gBrowser, + false, + "https://www.example.com/" + ); + BrowserTestUtils.startLoadingURIString(gBrowser, "https://www.example.com"); + await browserLoadedPromise; + + Assert.equal( + !!Glean.serp.abandonment.testGetValue(), + false, + "Should not have any abandonment events." + ); + + BrowserTestUtils.removeTab(tab); + + // Allow subsequent tests to use the default provider. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_aboutHome.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_aboutHome.js new file mode 100644 index 0000000000..9e9af43698 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_aboutHome.js @@ -0,0 +1,135 @@ +"use strict"; + +const SCALAR_ABOUT_HOME = "browser.engagement.navigation.about_home"; + +add_setup(async function () { + // about:home uses IndexedDB. However, the test finishes too quickly and doesn't + // allow it enougth time to save. So it throws. This disables all the uncaught + // exception in this file and that's the reason why we split about:home tests + // out of the other UsageTelemetry files. + ignoreAllUncaughtExceptions(); + + // Create two new search engines. Mark one as the default engine, so + // the test don't crash. We need to engines for this test as the searchbar + // in content doesn't display the default search engine among the one-off engines. + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + }, + { setAsDefault: true } + ); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch2", + keyword: "mozalias2", + }); + + // Move the second engine at the beginning of the one-off list. + let engineOneOff = Services.search.getEngineByName("MozSearch2"); + await Services.search.moveEngine(engineOneOff, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + Services.telemetry.canRecordExtended = oldCanRecord; + }); +}); + +add_task(async function test_abouthome_activitystream_simpleQuery() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + Services.fog.testResetFOG(); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + info("Load about:home."); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, "about:home"); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, "about:home"); + + info("Wait for ContentSearchUI search provider to initialize."); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition( + () => content.wrappedJSObject.gContentSearchController.defaultEngine + ); + }); + + info("Trigger a simple search, just test + enter."); + let p = BrowserTestUtils.browserStopped( + tab.linkedBrowser, + "https://example.com/?q=test+query" + ); + await typeInSearchField( + tab.linkedBrowser, + "test query", + "newtab-search-text" + ); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_ABOUT_HOME, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_ABOUT_HOME]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.abouthome", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "about_home", + value: "enter", + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Also also check Glean events. + const record = Glean.newtabSearch.issued.testGetValue(); + Assert.ok(!!record, "Must have recorded a search issuance"); + Assert.equal(record.length, 1, "One search, one event"); + Assert.deepEqual( + { + search_access_point: "about_home", + telemetry_id: "other-MozSearch", + }, + record[0].extra, + "Must have recorded the expected information." + ); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js new file mode 100644 index 0000000000..8049406d40 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js @@ -0,0 +1,502 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const WINDOW_HEIGHT = 768; +const WINDOW_WIDTH = 1024; + +// Note: example.org is used for the SERP page, and example.com is used to serve +// the ads. This is done to simulate different domains like the real servers. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + included: { + parent: { + selector: ".moz-carousel", + }, + children: [ + { + selector: ".moz-carousel-card", + countChildren: true, + }, + ], + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + included: { + parent: { + selector: ".refined-search-buttons", + }, + children: [ + { + selector: "a", + }, + ], + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + included: { + parent: { + selector: ".moz_ad", + }, + children: [ + { + selector: ".multi-col", + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + }, + excluded: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SIDEBAR, + included: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +async function promiseResize(width, height) { + return TestUtils.waitForCondition(() => { + return window.outerWidth === width && window.outerHeight === height; + }, "Waiting for window to resize"); +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + // The tests evaluate whether or not ads are visible depending on whether + // they are within the view of the window. To ensure the test results + // are consistent regardless of where they are launched, + // set the window size to something reasonable. + let originalWidth = window.outerWidth; + let originalHeight = window.outerHeight; + window.resizeTo(WINDOW_WIDTH, WINDOW_HEIGHT); + await promiseResize(WINDOW_WIDTH, WINDOW_HEIGHT); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + window.resizeTo(originalWidth, originalHeight); + await promiseResize(originalWidth, originalHeight); + resetTelemetry(); + }); +}); + +add_task(async function test_ad_impressions_with_one_carousel() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "3", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// This is to ensure we're not counting two carousel components as two +// separate components but as one record with a sum of the results. +add_task(async function test_ad_impressions_with_two_carousels() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel_doubled.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + // This is to ensure we've seen the other carousel regardless the + // size of the browser window. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let el = content.document + .getElementById("second-ad") + .getBoundingClientRect(); + // The 100 is just to guarantee we've scrolled past the element. + content.scrollTo(0, el.top + el.height + 100); + }); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "8", + ads_visible: "6", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task( + async function test_ad_impressions_with_carousels_with_outer_container() { + resetTelemetry(); + let url = getSERPUrl( + "searchTelemetryAd_components_carousel_outer_container.html" + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "3", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + } +); + +add_task(async function test_ad_impressions_with_carousels_tabhistory() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "https://www.example.com/some_url" + ); + await browserLoadedPromise; + + // Reset telemetry because we care about the telemetry upon going back. + resetTelemetry(); + + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goBack(); + await pageShowPromise; + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "3", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_hidden_carousels() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel_hidden.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "0", + ads_hidden: "4", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_carousel_scrolled_left() { + resetTelemetry(); + let url = getSERPUrl( + "searchTelemetryAd_components_carousel_first_element_non_visible.html" + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_carousel_below_the_fold() { + resetTelemetry(); + let url = getSERPUrl( + "searchTelemetryAd_components_carousel_below_the_fold.html" + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "0", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_ad_impressions_with_text_links() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SIDEBAR, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// An ad is considered visible if at least one link is within the viewable +// content area when the impression was taken. Since the user can scroll +// the page before ad impression is recorded, we should ensure that an +// ad that was scrolled onto the screen before the impression is taken is +// properly recorded. Additionally, some ads might have a large content +// area that extends beyond the viewable area, but as long as a single +// ad link was viewable within the area, we should count the ads as visible. +add_task(async function test_ad_visibility() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_visibility.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let el = content.document + .getElementById("second-ad") + .getBoundingClientRect(); + // The 100 is just to guarantee we've scrolled past the element. + content.scrollTo(0, el.top + el.height + 100); + }); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "6", + ads_visible: "4", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_impressions_without_ads() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js new file mode 100644 index 0000000000..9ecc4e8d92 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Checks that telemetry on the runtime performance of categorizing the SERP + * works as normal. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_tab_contains_measurement() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_components_text.html") + ); + await waitForPageWithAdImpressions(); + + await Services.fog.testFlushAllChildren(); + Assert.ok( + Glean.serp.adImpression.testGetValue().length, + "Should have received ad impressions." + ); + + let durations = Glean.serp.categorizationDuration.testGetValue(); + Assert.ok(durations.sum > 0, "Sum should be more than 0."); + + BrowserTestUtils.removeTab(tab); +}); + +// If the user opened a SERP and closed it quickly or navigated away from it +// and no ad impressions were recorded, we shouldn't record a measurement. +add_task(async function test_before_ad_impressions_recorded() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_components_text.html") + ); + BrowserTestUtils.removeTab(tab); + + Assert.ok( + !Glean.serp.adImpression.testGetValue(), + "Should not have an ad impression." + ); + + await Services.fog.testFlushAllChildren(); + let durations = Glean.serp.categorizationDuration.testGetValue(); + Assert.equal(durations, undefined, "Should not have received any values."); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_content.js new file mode 100644 index 0000000000..b17604badd --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_content.js @@ -0,0 +1,204 @@ +"use strict"; + +const BASE_PROBE_NAME = "browser.engagement.navigation."; +const SCALAR_CONTEXT_MENU = BASE_PROBE_NAME + "contextmenu"; +const SCALAR_ABOUT_NEWTAB = BASE_PROBE_NAME + "about_newtab"; + +add_setup(async function () { + // Create two new search engines. Mark one as the default engine, so + // the test don't crash. We need to engines for this test as the searchbar + // in content doesn't display the default search engine among the one-off engines. + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + }, + { setAsDefault: true } + ); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch2", + keyword: "mozalias2", + }); + + // Move the second engine at the beginning of the one-off list. + let engineOneOff = Services.search.getEngineByName("MozSearch2"); + await Services.search.moveEngine(engineOneOff, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + await PlacesUtils.history.clear(); + Services.telemetry.setEventRecordingEnabled("navigation", false); + Services.telemetry.canRecordExtended = oldCanRecord; + }); +}); + +add_task(async function test_context_menu() { + // Let's reset the Telemetry data. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + // Open a new tab with a page containing some text. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "data:text/plain;charset=utf8,test%20search" + ); + + info("Select all the text in the page."); + await SpecialPowers.spawn(tab.linkedBrowser, [""], async function () { + return new Promise(resolve => { + content.document.addEventListener("selectionchange", () => resolve(), { + once: true, + }); + content.document.getSelection().selectAllChildren(content.document.body); + }); + }); + + info("Open the context menu."); + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupPromise = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + BrowserTestUtils.synthesizeMouseAtCenter( + "body", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await popupPromise; + + info("Click on search."); + let searchItem = contextMenu.getElementsByAttribute( + "id", + "context-searchselect" + )[0]; + contextMenu.activateItem(searchItem); + + info("Validate the search metrics."); + + // Telemetry is not updated synchronously here, we must wait for it. + await TestUtils.waitForCondition(() => { + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + return Object.keys(scalars[SCALAR_CONTEXT_MENU] || {}).length == 1; + }, "This search must increment one entry in the scalar."); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_CONTEXT_MENU, + "search", + 1 + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.contextmenu", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "contextmenu", + value: null, + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + contextMenu.hidePopup(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_about_newtab() { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + Services.fog.testResetFOG(); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab", + false + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition(() => !content.document.hidden); + }); + + info("Trigger a simple serch, just text + enter."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await typeInSearchField( + tab.linkedBrowser, + "test query", + "newtab-search-text" + ); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_ABOUT_NEWTAB, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_ABOUT_NEWTAB]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.newtab", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "about_newtab", + value: "enter", + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Also also check Glean events. + const record = Glean.newtabSearch.issued.testGetValue(); + Assert.ok(!!record, "Must have recorded a search issuance"); + Assert.equal(record.length, 1, "One search, one event"); + Assert.deepEqual( + { + search_access_point: "about_newtab", + telemetry_id: "other-MozSearch", + }, + record[0].extra, + "Must have recorded the expected information." + ); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js new file mode 100644 index 0000000000..ce18f64e9f --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js @@ -0,0 +1,190 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * These tests check the number of ads clicked from a SERP containing a + * categorization impression. Existing tests already check for the counting ads + * and tracking clicks, and the categorization impression piggybacks off + * of it. Hence, this is just mostly a sanity check. + */ + +ChromeUtils.defineESModuleGetters(this, { + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + let promise = waitForDomainToCategoriesUpdate(); + await insertRecordIntoCollectionAndSync(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await promise; + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_load_serp_and_categorize() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); +}); + +add_task(async function test_load_serp_and_categorize_and_click_organic() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + ".organic a", + {}, + tab.linkedBrowser + ); + await promise; + + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_and_categorize_and_click_sponsored() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("a.ad", {}, tab.linkedBrowser); + await promise; + + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "1", + num_ads_visible: "2", + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js new file mode 100644 index 0000000000..d01141d826 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js @@ -0,0 +1,313 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test ensures we are correctly restarting a download of an attachment + * after a failure. We simulate failures by not caching the attachment in + * Remote Settings. + */ + +ChromeUtils.defineESModuleGetters(this, { + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [/^https:\/\/example.com/], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function waitForDownloadError() { + return TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes("Could not download file:") + ); + }); +} + +const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); +const db = client.db; + +// Shorten the timer so that tests don't have to wait too long. +const TIMEOUT_IN_MS = 250; +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + await db.clear(); + + // Set the state of the pref to false so that tests toggle the preference, + // triggering the map to be updated. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", false]], + }); + + let defaultDownloadSettings = { + ...TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS, + }; + + // Use a much shorter interval from the default preference that when we + // simulate download failures, we don't have to wait long before another + // download attempt. + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base = TIMEOUT_IN_MS; + + // Normally we add random time to avoid a failure resulting in everyone + // hitting the network at once. For tests, we remove this unless explicitly + // testing. + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust = 0; + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust = 0; + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS = { + ...defaultDownloadSettings, + }; + }); +}); + +add_task(async function test_download_after_failure() { + // Most cases, we should use a convenience function, but in this case, + // we want to explictly "forget" to include an attachment to cause a failure. + let { record, attachment } = await mockRecordWithAttachment({ + id: "example_id", + version: 1, + filename: "domain_category_mappings.json", + }); + await db.create(record); + await db.importChanges({}, Date.now()); + + let downloadError = waitForDownloadError(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await downloadError; + + // In between the download failure and one of download retries, cache + // the attachment so that the next download attempt will be successful. + client.attachments.cacheImpl.set(record.id, attachment); + await TestUtils.topicObserved("domain-to-categories-map-update-complete"); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_visible: "2", + num_ads_clicked: "0", + }, + ]); + + // Clean up. + await SpecialPowers.popPrefEnv(); + await resetCategorizationCollection(record); +}); + +add_task(async function test_download_after_multiple_failures() { + let { record } = await mockRecordWithAttachment({ + id: "example_id", + version: 1, + filename: "domain_category_mappings.json", + }); + await db.create(record); + await db.importChanges({}, Date.now()); + + let downloadError = waitForDownloadError(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await downloadError; + + // Following an initial download failure, the number of allowable retries + // should equal to the maximum number per session. + for ( + let i = 0; + i < TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxTriesPerSession; + ++i + ) { + await waitForDownloadError(); + } + + // To ensure we didn't attempt another download, wait more than what another + // download error should take. + let consoleObserved = false; + let timeout = false; + let firstPromise = waitForDownloadError().then(() => { + consoleObserved = true; + }); + let secondPromise = new Promise(resolve => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, TIMEOUT_IN_MS + 100) + ).then(() => (timeout = true)); + await Promise.race([firstPromise, secondPromise]); + Assert.equal(consoleObserved, false, "Encountered download failure"); + Assert.equal(timeout, true, "Timeout occured"); + + Assert.ok(SearchSERPDomainToCategoriesMap.empty, "Map is empty"); + + // Clean up. + await SpecialPowers.popPrefEnv(); + await resetCategorizationCollection(record); +}); + +add_task(async function test_cancel_download_timer() { + let { record } = await mockRecordWithAttachment({ + id: "example_id", + version: 1, + filename: "domain_category_mappings.json", + }); + await db.create(record); + await db.importChanges({}, Date.now()); + + let downloadError = waitForDownloadError(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await downloadError; + + // Changing the gating preference to false before the map is populated + // should cancel the download timer. + let observeCancel = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes( + "Cancel and nullify download timer." + ) + ); + }); + await SpecialPowers.popPrefEnv(); + await observeCancel; + + // To ensure we don't attempt another download, wait a bit over how long the + // the download error should take. + let consoleObserved = false; + let timeout = false; + let firstPromise = waitForDownloadError().then(() => { + consoleObserved = true; + }); + let secondPromise = new Promise(resolve => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, TIMEOUT_IN_MS + 100) + ).then(() => (timeout = true)); + await Promise.race([firstPromise, secondPromise]); + Assert.equal(consoleObserved, false, "Encountered download failure"); + Assert.equal(timeout, true, "Timeout occured"); + Assert.ok(SearchSERPDomainToCategoriesMap.empty, "Map is empty"); + + // Clean up. + await SpecialPowers.popPrefEnv(); + await resetCategorizationCollection(record); +}); + +add_task(async function test_download_adjust() { + // To test that we're actually adding a random delay to the base value, + // we set the base number to zero so that the next attempt should be + // instant but we'll wait in between 0 and 1000ms and expect the + // timer to elapse first. + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base = 0; + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust = 1000; + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust = 1000; + + let { record } = await mockRecordWithAttachment({ + id: "example_id", + version: 1, + filename: "domain_category_mappings.json", + }); + await db.create(record); + await db.importChanges({}, Date.now()); + + let downloadError = waitForDownloadError(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await downloadError; + + // The timer should finish before the next error. + let consoleObserved = false; + let timeout = false; + let firstPromise = waitForDownloadError().then(() => { + consoleObserved = true; + }); + let secondPromise = new Promise(resolve => + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, 250) + ).then(() => (timeout = true)); + await Promise.race([firstPromise, secondPromise]); + Assert.equal(timeout, true, "Timeout occured"); + Assert.equal(consoleObserved, false, "Encountered download failure"); + + await firstPromise; + Assert.equal(consoleObserved, true, "Encountered download failure"); + + // Clean up. + await SpecialPowers.popPrefEnv(); + await resetCategorizationCollection(record); + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base = TIMEOUT_IN_MS; + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust = 0; + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust = 0; +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js new file mode 100644 index 0000000000..03ddb75481 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js @@ -0,0 +1,263 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test ensures we are correctly extracting domains from a SERP. + */ + +ChromeUtils.defineESModuleGetters(this, { + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", +}); + +const TESTS = [ + { + title: "Extract domain from href (absolute URL) - one link.", + extractorInfos: [ + { + selectors: + '#test1 [data-layout="organic"] a[data-testid="result-title-a"]', + method: "href", + }, + ], + expectedDomains: ["foobar.com"], + }, + { + title: "Extract domain from href (absolute URL) - multiple links.", + extractorInfos: [ + { + selectors: + '#test2 [data-layout="organic"] a[data-testid="result-title-a"]', + method: "href", + }, + ], + expectedDomains: ["foo.com", "bar.com", "baz.com", "qux.com"], + }, + { + title: "Extract domain from href (relative URL).", + extractorInfos: [ + { + selectors: + '#test3 [data-layout="organic"] a[data-testid="result-title-a"]', + method: "href", + }, + ], + expectedDomains: ["example.org"], + }, + { + title: "Extract domain from data attribute - one link.", + extractorInfos: [ + { + selectors: "#test4 [data-dtld]", + method: "data-attribute", + options: { + dataAttributeKey: "dtld", + }, + }, + ], + expectedDomains: ["www.abc.com"], + }, + { + title: "Extract domain from data attribute - multiple links.", + extractorInfos: [ + { + selectors: "#test5 [data-dtld]", + method: "data-attribute", + options: { + dataAttributeKey: "dtld", + }, + }, + ], + expectedDomains: [ + "www.foo.com", + "www.bar.com", + "www.baz.com", + "www.qux.com", + ], + }, + { + title: "Extract domain from an href's query param value.", + extractorInfos: [ + { + selectors: + '#test6 .js-carousel-item-title, #test6 [data-layout="ad"] [data-testid="result-title-a"]', + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + expectedDomains: ["def.com"], + }, + { + title: + "Extract domain from an href's query param value containing an href.", + extractorInfos: [ + { + selectors: "#test7 a", + method: "href", + options: { + queryParamKey: "ad_domain", + queryParamValueIsHref: true, + }, + }, + ], + expectedDomains: ["def.com"], + }, + { + title: + "The param value contains an invalid href while queryParamValueIsHref enabled.", + extractorInfos: [ + { + selectors: "#test8 a", + method: "href", + options: { + queryParamKey: "ad_domain", + queryParamValueIsHref: true, + }, + }, + ], + expectedDomains: [], + }, + { + title: "Param value is missing from the href.", + extractorInfos: [ + { + selectors: "#test9 a", + method: "href", + options: { + queryParamKey: "ad_domain", + queryParamValueIsHref: true, + }, + }, + ], + expectedDomains: [], + }, + { + title: "Extraction preserves order of domains within the page.", + extractorInfos: [ + { + selectors: + '#test10 [data-layout="organic"] a[data-testid="result-title-a"]', + method: "href", + }, + { + selectors: "#test10 [data-dtld]", + method: "data-attribute", + options: { + dataAttributeKey: "dtld", + }, + }, + { + selectors: + '#test10 .js-carousel-item-title, #test7 [data-layout="ad"] [data-testid="result-title-a"]', + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + expectedDomains: ["foobar.com", "www.abc.com", "def.com"], + }, + { + title: "No elements match the selectors.", + extractorInfos: [ + { + selectors: + '#test11 [data-layout="organic"] a[data-testid="result-title-a"]', + method: "href", + }, + ], + expectedDomains: [], + }, + { + title: "Data attribute is present, but value is missing.", + extractorInfos: [ + { + selectors: "#test12 [data-dtld]", + method: "data-attribute", + options: { + dataAttributeKey: "dtld", + }, + }, + ], + expectedDomains: [], + }, + { + title: "Query param is present, but value is missing.", + extractorInfos: [ + { + selectors: '#test13 [data-layout="ad"] [data-testid="result-title-a"]', + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + expectedDomains: [], + }, + { + title: "Non-standard URL scheme.", + extractorInfos: [ + { + selectors: + '#test14 [data-layout="organic"] a[data-testid="result-title-a"]', + method: "href", + }, + ], + expectedDomains: [], + }, +]; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.serpEventTelemetry.enabled", true], + ["browser.search.serpEventTelemetryCategorization.enabled", true], + ], + }); + + await SearchSERPTelemetry.init(); + + registerCleanupFunction(async () => { + resetTelemetry(); + }); +}); + +add_task(async function test_domain_extraction_heuristics() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryDomainExtraction.html"); + info( + "Load a sample SERP where domains need to be extracted in different ways." + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + for (let currentTest of TESTS) { + if (currentTest.title) { + info(currentTest.title); + } + let expectedDomains = new Set(currentTest.expectedDomains); + let actualDomains = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [currentTest.extractorInfos], + extractorInfos => { + const { domainExtractor } = ChromeUtils.importESModule( + "resource:///actors/SearchSERPTelemetryChild.sys.mjs" + ); + return domainExtractor.extractDomainsFromDocument( + content.document, + extractorInfos + ); + } + ); + + Assert.deepEqual( + Array.from(actualDomains), + Array.from(expectedDomains), + "Domains should have been extracted correctly." + ); + } + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js new file mode 100644 index 0000000000..f328bb4f79 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * These tests check that changing the region actually results in reporting the + * correct changes. Other tests that include region just report the default + * used by the test. + */ + +ChromeUtils.defineESModuleGetters(this, { + Region: "resource://gre/modules/Region.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +const originalHomeRegion = Region.home; +const originalCurrentRegion = Region.current; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + let promise = waitForDomainToCategoriesUpdate(); + await insertRecordIntoCollectionAndSync(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await promise; + + info("Change region to DE."); + Region._setHomeRegion("DE", false); + Assert.equal(Region.home, "DE", "Region"); + + registerCleanupFunction(async () => { + Region._setHomeRegion(originalHomeRegion); + Region._setCurrentRegion(originalCurrentRegion); + + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_categorize_page_with_different_region() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: "DE", + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js new file mode 100644 index 0000000000..b7edb8763f --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * This test ensures we are correctly reporting categorized domains from a SERP. + */ + +ChromeUtils.defineESModuleGetters(this, { + CATEGORIZATION_SETTINGS: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [ + /^https:\/\/example\.com\/ad/, + /^https:\/\/www\.test(1[3456789]|2[01234])\.com/, + ], + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); +const db = client.db; + +let categorizationRecord; +let categorizationAttachment; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + + let { record, attachment } = await insertRecordIntoCollection(); + categorizationRecord = record; + categorizationAttachment = attachment; + + let promise = waitForDomainToCategoriesUpdate(); + await syncCollection(record); + // Enable the preference since all tests rely on it to be turned on. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await promise; + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + await db.clear(); + }); +}); + +add_task(async function test_categorization_reporting() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); +}); + +add_task(async function test_no_reporting_if_download_failure() { + resetTelemetry(); + + // Delete the attachment associated with the record so that syncing + // will cause an error. + await client.attachments.cacheImpl.delete(categorizationRecord.id); + + let observeDownloadError = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes("Could not download file:") + ); + }); + // Since the preference is already enabled, and the map is filled we trigger + // the map to be updated via an RS sync. The download failure should cause the + // map to remain empty. + await syncCollection(categorizationRecord); + await observeDownloadError; + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([]); + + // Re-insert the attachment for other tests. + await client.attachments.cacheImpl.set( + categorizationRecord.id, + categorizationAttachment + ); +}); + +add_task(async function test_no_reporting_if_no_records() { + resetTelemetry(); + + let observeNoRecords = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes( + "No records found for domain-to-categories map." + ) + ); + }); + await syncCollection(); + await observeNoRecords; + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a sample SERP with organic results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([]); +}); + +// Per a request from Data Science, we need to limit the number of domains +// categorized to 10 non ad domains and 10 ad domains. +add_task(async function test_reporting_limited_to_10_domains_of_each_kind() { + resetTelemetry(); + + await insertRecordIntoCollectionAndSync(); + + let url = getSERPUrl( + "searchTelemetryDomainCategorizationCapProcessedDomains.html" + ); + info( + "Load a sample SERP with more than 10 organic results and more than 10 sponsored results." + ); + let domainsCategorizedPromise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await domainsCategorizedPromise; + + await BrowserTestUtils.removeTab(tab); + + assertCategorizationValues([ + { + organic_category: "0", + organic_num_domains: + CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE.toString(), + organic_num_inconclusive: "0", + organic_num_unknown: "10", + sponsored_category: "2", + sponsored_num_domains: + CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE.toString(), + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "8", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "12", + }, + ]); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js new file mode 100644 index 0000000000..cfb8590960 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js @@ -0,0 +1,287 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * These tests check that we report the categorization if the SERP is loaded, + * and the user idles. The tests also check that if we report the + * categorization and trigger another event that could cause a reporting, we + * don't cause more than one categorization to be reported. + */ + +ChromeUtils.defineESModuleGetters(this, { + SearchSERPCategorizationEventScheduler: + "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchTestUtils.useMockIdleService(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + + // On startup, the event scheduler is initialized. + // If serpEventTelemetryCategorization is already true, the instance of the + // class will be subscribed to to the real idle service instead of the mock + // idle service. If it's false, toggling the preference (which happens later + // in this setup) will initialize it. + if ( + Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + SearchSERPCategorizationEventScheduler.uninit(); + SearchSERPCategorizationEventScheduler.init(); + } + await waitForIdle(); + + let promise = waitForDomainToCategoriesUpdate(); + await insertRecordIntoCollectionAndSync(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await promise; + + registerCleanupFunction(async () => { + // The scheduler uses the mock idle service. + SearchSERPCategorizationEventScheduler.uninit(); + SearchSERPCategorizationEventScheduler.init(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_categorize_serp_and_wait() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + assertCategorizationValues([]); + + promise = waitForAllCategorizedEvents(); + SearchTestUtils.idleService._fireObservers("idle"); + await promise; + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); + + info("Ensure we don't record a duplicate of this event."); + resetTelemetry(); + SearchTestUtils.idleService._fireObservers("idle"); + SearchTestUtils.idleService._fireObservers("active"); + await BrowserTestUtils.removeTab(tab); + + assertCategorizationValues([]); +}); + +add_task(async function test_categorize_serp_open_multiple_tabs() { + resetTelemetry(); + + let tabs = []; + let expectedResults = []; + for (let i = 0; i < 5; ++i) { + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + tabs.push(tab); + // Pushing expected results into a single array to avoid having a massive, + // unreadable array. + expectedResults.push({ + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }); + } + + info("Simulate idle event and wait for results."); + let promise = waitForAllCategorizedEvents(); + SearchTestUtils.idleService._fireObservers("idle"); + await promise; + assertCategorizationValues(expectedResults); + + info("Ensure we don't record a duplicate of any event."); + resetTelemetry(); + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } + assertCategorizationValues([]); +}); + +// Ensures we don't double record a categorization event if the closed the tab +// before an idle event. +add_task(async function test_categorize_serp_close_tab_and_wait() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + assertCategorizationValues([]); + + promise = waitForSingleCategorizedEvent(); + await BrowserTestUtils.removeTab(tab); + await promise; + + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); + + info("Ensure we don't record a duplicate of this event."); + resetTelemetry(); + SearchTestUtils.idleService._fireObservers("idle"); + assertCategorizationValues([]); +}); + +add_task(async function test_categorize_serp_open_ad_and_wait() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + info("Open ad in new tab."); + let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + ".ad", + { button: 1 }, + tab.linkedBrowser + ); + let tab2 = await promiseTabOpened; + + assertCategorizationValues([]); + + promise = waitForAllCategorizedEvents(); + SearchTestUtils.idleService._fireObservers("idle"); + info("Waiting for categorized events."); + await promise; + + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "1", + num_ads_visible: "2", + }, + ]); + + // Clean up. + await BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js new file mode 100644 index 0000000000..cb95164221 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js @@ -0,0 +1,202 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* + * These tests check that we report the SERP categorization upon waking a + * computer and enough time has passed. + */ + +ChromeUtils.defineESModuleGetters(this, { + CATEGORIZATION_SETTINGS: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPCategorizationEventScheduler: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + TELEMETRY_CATEGORIZATION_KEY: + "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + // The search telemetry entry responsible for targeting the specific results. + domainExtraction: { + ads: [ + { + selectors: "[data-ad-domain]", + method: "data-attribute", + options: { + dataAttributeKey: "adDomain", + }, + }, + { + selectors: ".ad", + method: "href", + options: { + queryParamKey: "ad_domain", + }, + }, + ], + nonAds: [ + { + selectors: "#results .organic a", + method: "href", + }, + ], + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + let oldWakeTimeout = CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS; + + // Use a sane timeout. + CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS = 100; + + SearchTestUtils.useMockIdleService(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + // On startup, the event scheduler is initialized. + // If serpEventTelemetryCategorization is already true, the instance of the + // class will be subscribed to to the real idle service instead of the mock + // idle service. If it's false, toggling the preference (which happens later + // in this setup) will initialize it. + if ( + Services.prefs.getBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled" + ) + ) { + SearchSERPCategorizationEventScheduler.uninit(); + SearchSERPCategorizationEventScheduler.init(); + } + await waitForIdle(); + + let promise = waitForDomainToCategoriesUpdate(); + await insertRecordIntoCollectionAndSync(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetryCategorization.enabled", true]], + }); + await promise; + + registerCleanupFunction(async () => { + // The scheduler uses the mock idle service. + SearchSERPCategorizationEventScheduler.uninit(); + SearchSERPCategorizationEventScheduler.init(); + CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS = oldWakeTimeout; + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_categorize_serp_and_sleep() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + assertCategorizationValues([]); + + info("Wait enough between the categorization and the sleep timeout."); + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 200)); + + info("Simulate a wake notification."); + promise = waitForAllCategorizedEvents(); + SearchTestUtils.idleService._fireObservers("wake_notification"); + await promise; + + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); + + info("Ensure we don't record a duplicate of this event."); + resetTelemetry(); + SearchTestUtils.idleService._fireObservers("idle"); + SearchTestUtils.idleService._fireObservers("active"); + SearchTestUtils.idleService._fireObservers("wake_notification"); + await BrowserTestUtils.removeTab(tab); + + assertCategorizationValues([]); +}); + +add_task(async function test_categorize_serp_and_sleep_not_long_enough() { + resetTelemetry(); + + // Use a really long timeout. + CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS = 500_000; + + let url = getSERPUrl("searchTelemetryDomainCategorizationReporting.html"); + info("Load a SERP with organic and sponsored results."); + let promise = waitForPageWithCategorizedDomains(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await promise; + + assertCategorizationValues([]); + + info("Wait as long as the previous test."); + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 200)); + assertCategorizationValues([]); + + info("Simulate a wake notification."); + SearchTestUtils.idleService._fireObservers("wake_notification"); + assertCategorizationValues([]); + + // Closing the tab should record the telemetry. + await BrowserTestUtils.removeTab(tab); + assertCategorizationValues([ + { + organic_category: "3", + organic_num_domains: "1", + organic_num_inconclusive: "0", + organic_num_unknown: "0", + sponsored_category: "4", + sponsored_num_domains: "2", + sponsored_num_inconclusive: "0", + sponsored_num_unknown: "0", + mappings_version: "1", + app_version: APP_MAJOR_VERSION, + channel: CHANNEL, + region: REGION, + partner_code: "ff", + provider: "example", + tagged: "true", + num_ads_clicked: "0", + num_ads_visible: "2", + }, + ]); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js new file mode 100644 index 0000000000..791e29a01f --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js @@ -0,0 +1,201 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on cacheable links. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + included: { + parent: { + selector: ".moz-carousel", + }, + children: [ + { + selector: ".moz-carousel-card", + countChildren: true, + }, + ], + related: { + selector: "button", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + included: { + parent: { + selector: ".moz_ad", + }, + children: [ + { + selector: ".multi-col", + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + related: { + selector: "button", + }, + }, + excluded: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_click_cached_page() { + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let cacheableUrl = + "https://example.com/browser/browser/components/search/test/browser/telemetry/cacheable.html"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + cacheableUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#non_ads_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + gBrowser.goBack(); + await waitForPageWithAdImpressions(); + + pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + cacheableUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#non_ads_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "5", + ads_visible: "5", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "5", + ads_visible: "5", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js new file mode 100644 index 0000000000..72e26639fb --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js @@ -0,0 +1,218 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests check when a SERP retrieves data from the BFCache as SERPs + * typically set their response headers with Cache-Control as private. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [/^https:\/\/example.com/], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +async function goBack(tab, callback = async () => {}) { + info("Go back."); + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goBack(); + await pageShowPromise; + await callback(); +} + +async function goForward(tab, callback = async () => {}) { + info("Go forward."); + let pageShowPromise = BrowserTestUtils.waitForContentEvent( + tab.linkedBrowser, + "pageshow" + ); + tab.linkedBrowser.goForward(); + await pageShowPromise; + await callback(); +} + +// This test loads a cached SERP and checks returning to it and interacting +// with elements on the page don't count the events more than once. +// This is a proxy for ensuring we remove event listeners. +add_task(async function test_cached_serp() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + info("Load search page."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + for (let index = 0; index < 3; ++index) { + info("Load non-search page."); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, true); + BrowserTestUtils.startLoadingURIString( + tab.linkedBrowser, + "https://www.example.com" + ); + await loadPromise; + await goBack(tab, async () => { + await waitForPageWithAdImpressions(); + }); + } + + info("Click on searchbox."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "input", + {}, + tab.linkedBrowser + ); + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + Assert.equal( + engagements.length, + 1, + "There should be 1 engagement event recorded." + ); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_back_and_forward_serp_to_serp() { + await SpecialPowers.pushPrefEnv({ + // This has to be disabled or else using back and forward in the test won't + // trigger responses in the network listener in SearchSERPTelemetry. The + // page will still load from a BFCache. + set: [["fission.bfcacheInParent", false]], + }); + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + info("Load search page."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false); + info("Click on a suggested search term."); + BrowserTestUtils.synthesizeMouseAtCenter("#suggest", {}, tab.linkedBrowser); + await loadPromise; + await waitForPageWithAdImpressions(); + + for (let index = 0; index < 3; ++index) { + info("Return to first search page."); + await goBack(tab, async () => { + await waitForPageWithAdImpressions(); + }); + info("Return to second search page."); + await goForward(tab, async () => { + await waitForPageWithAdImpressions(); + }); + } + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + let abandonments = Glean.serp.abandonment.testGetValue() ?? []; + Assert.equal(engagements.length, 1, "There should be 1 engagement."); + Assert.equal(abandonments.length, 6, "There should be 6 abandonments."); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_back_and_forward_content_to_serp_to_serp() { + await SpecialPowers.pushPrefEnv({ + // This has to be disabled or else using back and forward in the test won't + // trigger responses in the network listener in SearchSERPTelemetry. The + // page will still load from a BFCache. + set: [["fission.bfcacheInParent", false]], + }); + resetTelemetry(); + + info("Load non-search page."); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://www.example.com/" + ); + + info("Load search page."); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, true); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, url); + await loadPromise; + await waitForPageWithAdImpressions(); + + loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false); + info("Click on a suggested search term."); + BrowserTestUtils.synthesizeMouseAtCenter("#suggest", {}, tab.linkedBrowser); + await loadPromise; + await waitForPageWithAdImpressions(); + + info("Return to first search page."); + await goBack(tab, async () => { + await waitForPageWithAdImpressions(); + }); + + info("Return to non-search page."); + await goBack(tab); + + info("Return to first search page."); + await goForward(tab, async () => { + await waitForPageWithAdImpressions(); + }); + + info("Return to second search page."); + await goForward(tab, async () => { + await waitForPageWithAdImpressions(); + }); + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + let abandonments = Glean.serp.abandonment.testGetValue() ?? []; + Assert.equal(engagements.length, 1, "There should be 1 engagement."); + Assert.equal(abandonments.length, 3, "There should be 3 abandonments."); + + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js new file mode 100644 index 0000000000..a7ea62ebd5 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js @@ -0,0 +1,633 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load a SERP that has multiple ways of refining a search term + * within content, or moving it into another search engine. It is also common + * for providers to remove tracking params. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_searchbox_with_content.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_searchbox_with_content_redirect.html/, + ], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + shoppingTab: { + selector: "nav a", + regexp: "&page=shopping", + inspectRegexpInSERP: true, + }, + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + nonAd: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + included: { + parent: { + selector: ".refined-search-buttons", + }, + children: [ + { + selector: "a", + }, + ], + }, + topDown: true, + nonAd: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +// "Tabs" are considered to be links the navigation of a SERP. Their hrefs +// may look similar to a search page, including related searches. +add_task(async function test_click_tab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#images", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Ensure that shopping links on a page with many non-ad link regular +// expressions doesn't get confused for a non-ads link. +add_task(async function test_click_shopping() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#shopping", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "true", + is_private: "false", + shopping_tab_displayed: "true", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_click_related_search_in_new_tab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-new-tab", + {}, + tab.linkedBrowser + ); + let tab2 = await tabPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// We consider regular expressions in nonAdsLinkRegexps and searchPageRegexp +// as valid non ads links when recording an engagement event. +add_task(async function test_click_redirect_search_in_newtab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-redirect", + {}, + tab.linkedBrowser + ); + let tab2 = await tabPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// Ensure if a user does a search that uses one of the in-content sources, +// we clear the cached source value. +add_task(async function test_content_source_reset() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Do a text search to trigger a defined target. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "form input", + {}, + tab.linkedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await pageLoadPromise; + + // Click on a related search that will load within the same page and should + // have an unknown target. + await waitForPageWithAdImpressions(); + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-in-page", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// This test also deliberately includes an anchor with a reserved character in +// the href that gets parsed on page load. This is because when the URL is +// requested and observed in the network process, it is converted into a +// percent encoded string, so we want to ensure we're categorizing the +// component properly. This can happen with refinement buttons. +add_task(async function test_click_refinement_button() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test%27s"; + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js new file mode 100644 index 0000000000..fbe6f4fc73 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures that recorded telemetry is consistent even with multiple + * tabs opened and closed. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_nonAdsLink_redirect/, + ], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + // This isn't contained in any of the HTML examples but the + // presence of the entry ensures that if it is not found during + // a topDown examination, the next element in the array is + // inspected and found. + selector: "textarea", + }, + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +// Deliberately make the web isolated process count as small as possible +// so that we don't have to create a ton of tabs to reuse a process. +const MAX_IPC = 1; +const TABS_TO_OPEN = 2; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.serpEventTelemetry.enabled", true], + ["dom.ipc.processCount.webIsolated", MAX_IPC], + ], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +async function do_test(tab, impressionId, switchTab) { + if (switchTab) { + await BrowserTestUtils.switchTab(gBrowser, tab); + } + await BrowserTestUtils.synthesizeMouseAtCenter( + "input", + {}, + tab.linkedBrowser + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#images", + {}, + tab.linkedBrowser + ); + + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, true); + + await Services.fog.testFlushAllChildren(); + let engagements = Glean.serp.engagement.testGetValue() ?? []; + Assert.equal(engagements.length, 2, "Should have two events recorded."); + + Assert.deepEqual( + engagements[0].extra, + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + impression_id: impressionId, + }, + "Search box engagement event should match." + ); + Assert.deepEqual( + engagements[1].extra, + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + impression_id: impressionId, + }, + "Non ads page engagement event should match." + ); + resetTelemetry(); +} + +// This test deliberately opens a lot of tabs to ensure SERPs share the +// same process. It interacts with the page to ensure the engagement +// has the correct recording, especially the impression id which can be out of +// sync if data in the child process isn't cached properly. +add_task(async function test_multiple_tabs_forward() { + resetTelemetry(); + + let tabs = []; + let pid; + + // Open multiple tabs. + for (let index = 0; index < TABS_TO_OPEN; ++index) { + let url = getSERPUrl( + "searchTelemetryAd_searchbox_with_content.html", + `hello+world+${index}` + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + tabs.push(tab); + let currentPid = E10SUtils.getBrowserPids(tab.linkedBrowser).at(0); + if (pid == null) { + pid = currentPid; + } else { + Assert.ok(pid == currentPid, "The process ID should be the same."); + } + } + + // Extract the impression IDs. + await Services.fog.testFlushAllChildren(); + let recordedImpressions = Glean.serp.impression.testGetValue() ?? []; + let impressionIds = recordedImpressions.map( + impression => impression.extra.impression_id + ); + + // Reset telemetry because we're not concerned about inspecting every + // impression event. + resetTelemetry(); + + for (let index = 0; index < TABS_TO_OPEN; ++index) { + let tab = tabs[index]; + let impressionId = impressionIds[index]; + await do_test(tab, impressionId, true); + BrowserTestUtils.removeTab(tab); + } +}); + +add_task(async function test_multiple_tabs_backward() { + resetTelemetry(); + + let tabs = []; + let pid; + + for (let index = 0; index < TABS_TO_OPEN; ++index) { + let url = getSERPUrl( + "searchTelemetryAd_searchbox_with_content.html", + `hello+world+${index}` + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + tabs.push(tab); + let currentPid = E10SUtils.getBrowserPids(tab.linkedBrowser).at(0); + if (pid == null) { + pid = currentPid; + } else { + Assert.ok(pid == currentPid, "The process ID should be the same."); + } + } + + // Extract the impression IDs. + await Services.fog.testFlushAllChildren(); + let recordedImpressions = Glean.serp.impression.testGetValue() ?? []; + let impressionIds = recordedImpressions.map( + impression => impression.extra.impression_id + ); + + // Reset telemetry because we're not concerned about inspecting every + // impression event. + resetTelemetry(); + + for (let index = TABS_TO_OPEN - 1; index >= 0; --index) { + let tab = tabs[index]; + let impressionId = impressionIds[index]; + await do_test(tab, impressionId, false); + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js new file mode 100644 index 0000000000..d351234d50 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js @@ -0,0 +1,146 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on links that are non ads. Non ads can have + * slightly different behavior from ads. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +// If an anchor is a non_ads_link and it doesn't match a non-ads regular +// expression, it should still be categorize it as a non ad. +add_task(async function test_click_non_ads_link() { + await waitForIdle(); + + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click a non ad. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "13", + ads_visible: "13", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + + // Reset state for other tests. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); + +// Click on an non-ad element while no ads are present. +add_task(async function test_click_non_ad_with_no_ads() { + await waitForIdle(); + + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + "https://example.com/hello_world" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link", + {}, + tab.linkedBrowser + ); + await browserLoadedPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + + // Reset state for other tests. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js new file mode 100644 index 0000000000..6d93707d68 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js @@ -0,0 +1,387 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and check that query params that are changed either + * by the browser or in the page after click are still properly recognized + * as ads. + * + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + included: { + parent: { + selector: ".moz_ad", + }, + children: [ + { + selector: ".multi-col", + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +// Baseline test clicking on either link properly categorizes both properly. +add_task(async function test_click_links() { + let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html"); + + info("Load SERP."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + info("Click on ad link."); + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#ad_link", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + info("Load SERP again."); + BrowserTestUtils.startLoadingURIString(gBrowser, url); + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await waitForPageWithAdImpressions(); + + info("Click on site link."); + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#ad_sitelink", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean up. + BrowserTestUtils.removeTab(tab); + resetTelemetry(); +}); + +add_task(async function test_click_link_with_more_parameters() { + let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html"); + + info("Load SERP."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + info("After ad impressions, add query parameters to DOM element."); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let el = content.document.getElementById("ad_sitelink"); + let domUrl = new URL(el.href); + domUrl.searchParams.set("example", "param"); + el.setAttribute("href", domUrl.toString()); + }); + + info("Click on site link."); + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#ad_sitelink", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean up. + BrowserTestUtils.removeTab(tab); + resetTelemetry(); +}); + +add_task(async function test_click_link_with_fewer_parameters() { + let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html"); + + info("Load SERP."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + info("After ad impressions, remove a query parameter from a DOM element."); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let el = content.document.getElementById("ad_sitelink"); + let domUrl = new URL(el.href); + domUrl.searchParams.delete("foo"); + el.setAttribute("href", domUrl.toString()); + }); + + info("Click on site link."); + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#ad_sitelink", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean up. + BrowserTestUtils.removeTab(tab); + resetTelemetry(); +}); + +add_task(async function test_click_link_with_reordered_parameters() { + let url = getSERPUrl("searchTelemetryAd_components_query_parameters.html"); + + info("Load SERP."); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + info("After ad impressions, re-sort the query params."); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + let el = content.document.getElementById("ad_sitelink"); + let domUrl = new URL(el.href); + domUrl.searchParams.sort(); + el.setAttribute("href", domUrl.toString()); + }); + + info("Click on site link."); + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#ad_sitelink", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean up. + BrowserTestUtils.removeTab(tab); + resetTelemetry(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js new file mode 100644 index 0000000000..5d7f2ee408 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js @@ -0,0 +1,372 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on both ad and non-ad links that can be + * redirected. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_nonAdsLink_redirect.html/, + ], + extraAdServersRegexps: [ + /^https:\/\/example\.com\/ad/, + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/redirect_ad/, + ], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_click_non_ads_link_redirected() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_redirected", + {}, + tab.linkedBrowser + ); + + await browserLoadedPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "14", + ads_visible: "14", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// If a provider does a re-direct and we open it in a new tab, we should +// record the click and have the correct number of engagements. +add_task(async function test_click_non_ads_link_redirected_new_tab() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let redirectUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_nonAdsLink_redirect.html"; + let targetUrl = "https://example.com/hello_world"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + + await SpecialPowers.spawn(tab.linkedBrowser, [redirectUrl], urls => { + content.document + .getElementById(["non_ads_link"]) + .addEventListener("click", e => { + e.preventDefault(); + content.window.open([urls], "_blank"); + }); + content.document.getElementById("non_ads_link").click(); + }); + let tab2 = await tabPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "14", + ads_visible: "14", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); + +// Some providers load a URL of a non ad within a subframe before loading the +// target website in the top level frame. +add_task(async function test_click_non_ads_link_redirect_non_top_level() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_redirected_no_top_level", + {}, + tab.linkedBrowser + ); + + await browserPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "14", + ads_visible: "14", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_multiple_redirects_non_ad_link() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_multiple_redirects", + {}, + tab.linkedBrowser + ); + + await browserLoadedPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "14", + ads_visible: "14", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_click_ad_link_redirected() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let browserLoadedPromise = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true, + targetUrl + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad_link_redirect", + {}, + tab.linkedBrowser + ); + + await browserLoadedPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "14", + ads_visible: "14", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_click_ad_link_redirected_new_tab() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = "https://example.com/hello_world"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad_link_redirect", + { button: 1 }, + tab.linkedBrowser + ); + let tab2 = await tabPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "14", + ads_visible: "14", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js new file mode 100644 index 0000000000..b30a7bc0c1 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js @@ -0,0 +1,457 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs and click on links that can either be ads or non ads + * and verifies that the engagement events and the target associated with them + * are correct. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [ + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_nonAdsLink_redirect.html/, + ], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + included: { + parent: { + selector: ".moz-carousel", + }, + children: [ + { + selector: ".moz-carousel-card", + countChildren: true, + }, + ], + related: { + selector: "button", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + included: { + parent: { + selector: ".moz_ad", + }, + children: [ + { + selector: ".multi-col", + type: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + related: { + selector: "button", + }, + }, + excluded: { + parent: { + selector: ".rhs", + }, + }, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + included: { + parent: { + selector: "form", + }, + children: [ + { + // This isn't contained in any of the HTML examples but the + // presence of the entry ensures that if it is not found during + // a topDown examination, the next element in the array is + // inspected and found. + selector: "textarea", + }, + { + selector: "input", + }, + ], + related: { + selector: "div", + }, + }, + topDown: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +// This is used to check if not providing an nonAdsLinkRegexp can still +// reliably categorize non_ads_links. +const TEST_PROVIDER_INFO_NO_NON_ADS_REGEXP = [ + { + ...TEST_PROVIDER_INFO[0], + nonAdsLinkRegexps: [], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +// This test ensures clicking a non-first link in a component registers the +// proper component. This is because the first link of a component does the +// heavy lifting in finding the parent and best categorization of the +// component. Subsequent anchors that have the same parent get grouped into it. +// Additionally, this test deliberately has ads with different paths so that +// there are no collisions in hrefs. +add_task(async function test_click_second_ad_in_component() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#deep_ad_sitelink", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "5", + ads_visible: "5", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// If a provider appends query parameters to a link after the page has been +// parsed, we should still be able to record the click. +add_task(async function test_click_ads_link_modified() { + resetTelemetry(); + + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + let target = content.document.getElementById("deep_ad_sitelink"); + let href = target.getAttribute("href"); + target.setAttribute("href", href + "?foo=bar"); + content.document.getElementById("deep_ad_sitelink").click(); + }); + await browserLoadedPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "5", + ads_visible: "5", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Search box is a special case which has to be tracked in the child process. +add_task(async function test_click_and_submit_incontent_searchbox() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click on the searchbox. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "form input", + {}, + tab.linkedBrowser + ); + EventUtils.synthesizeKey("KEY_Enter"); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Click an auto-suggested term. The element that is clicked is related +// to the searchbox but not in search-telemetry-v2 because it can be too +// difficult to determine ahead of time since the elements are generated +// dynamically. So instead it should listen to an element higher in the DOM. +add_task(async function test_click_autosuggest() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click an autosuggested term. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#suggest", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// Carousel related buttons expand content. +add_task(async function test_click_carousel_expand() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_carousel.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + // Click a button that is expected to expand. + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.querySelector("button").click(); + }); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.EXPANDED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + ads_loaded: "4", + ads_visible: "3", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +// This test clicks a link that has apostrophes in the both the path and list +// of query parameters, and uses search telemetry with no nonAdsRegexps defined, +// which will force us to cache every non ads link in a map and pass it back to +// the parent. +// If this test fails, it means we're doing the conversion wrong, because when +// we observe the clicked URL in the parent process, it should look exactly the +// same as how it was saved in the hrefToComponent map. +add_task(async function test_click_link_with_special_characters_in_path() { + SearchSERPTelemetry.overrideSearchTelemetryForTests( + TEST_PROVIDER_INFO_NO_NON_ADS_REGEXP + ); + await waitForIdle(); + + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_components_text.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange( + gBrowser, + "https://example.com/path'?hello_world&foo=bar%27s" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#non_ads_link_with_special_characters_in_path", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "5", + ads_visible: "5", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + + // Reset state for other tests. + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js new file mode 100644 index 0000000000..4f943fe92d --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js @@ -0,0 +1,350 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load ads and organic links in new windows. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function load_serp_in_new_window_with_pref_and_click_ad() { + info("Set browser.link.open_newwindow to open _blank in new window."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.link.open_newwindow", 2]], + }); + + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetryAd.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + info("Wait for page impression."); + await waitForPageWithAdImpressions(); + + info("Open ad link in a new window."); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.getElementById("ad1").setAttribute("target", "_blank"); + }); + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/ad", + }); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + let newWindow = await newWindowPromise; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + "browser.search.adclicks.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean-up. + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(newWindow); + resetTelemetry(); +}); + +add_task(async function load_serp_in_new_window_with_pref_and_click_organic() { + info("Set browser.link.open_newwindow to open _blank in new window."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.link.open_newwindow", 2]], + }); + + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetry.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + info("Wait for page impression."); + await waitForPageWithAdImpressions(); + + info("Open organic link in a new window."); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.querySelector("a").setAttribute("target", "_blank"); + }); + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/otherpage", + }); + await BrowserTestUtils.synthesizeMouseAtCenter("a", {}, tab.linkedBrowser); + let newWindow = await newWindowPromise; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [], + }, + ]); + + // Clean-up. + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(newWindow); + resetTelemetry(); +}); + +add_task(async function load_serp_in_new_window_with_context_menu() { + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetryAd.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + info("Wait for page impression."); + await waitForPageWithAdImpressions(); + + info("Open context menu."); + let contextMenu = document.getElementById("contentAreaContextMenu"); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad1", + { type: "contextmenu", button: 2 }, + tab.linkedBrowser + ); + await contextMenuPromise; + + info("Click on Open Link in New Window"); + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/ad", + }); + let openLinkInNewWindow = contextMenu.querySelector("#context-openlink"); + contextMenu.activateItem(openLinkInNewWindow); + let newWindow = await newWindowPromise; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + "browser.search.adclicks.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean-up. + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(newWindow); + resetTelemetry(); +}); + +add_task( + async function load_multiple_serps_with_different_search_terms_and_click_ad() { + info("Set browser.link.open_newwindow to open _blank in new window."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.link.open_newwindow", 2]], + }); + + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetryAd.html"); + let formattedUrl1 = new URL(url); + formattedUrl1.searchParams.set("s", "test1"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + info("Wait for page impression."); + await waitForPageWithAdImpressions(); + + info("Load SERP in a new tab with a different search term."); + url = getSERPUrl("searchTelemetryAd.html"); + let formattedUrl2 = new URL(url); + formattedUrl2.searchParams.set("s", "test2"); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + formattedUrl2.href + ); + info("Wait for page impression of tab 2."); + await waitForPageWithAdImpressions(); + + Assert.notEqual( + formattedUrl1.searchParams.get("s"), + formattedUrl2.searchParams.get("s"), + "The search query param in both tabs are different." + ); + + info("Open ad link of tab 2 in a new window."); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.getElementById("ad1").setAttribute("target", "_blank"); + }); + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/ad", + }); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad1", + {}, + tab2.linkedBrowser + ); + let newWindow = await newWindowPromise; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 2 }, + "browser.search.withads.unknown": { "example:tagged": 2 }, + "browser.search.adclicks.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean-up. + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + await BrowserTestUtils.closeWindow(newWindow); + resetTelemetry(); + } +); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js new file mode 100644 index 0000000000..ea7556c8f6 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js @@ -0,0 +1,135 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests load SERPs in Private Browsing Mode. Existing tests do so in + * non-Private Browsing Mode. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function load_2_pbm_serps_and_1_non_pbm_serp() { + info("Open private browsing window."); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetry.html"); + let tab = await BrowserTestUtils.openNewForegroundTab( + privateWindow.gBrowser, + url + ); + info("Wait for page impression."); + await waitForPageWithAdImpressions(); + + info("Load another SERP in the same tab."); + url = getSERPUrl("searchTelemetryAd.html"); + BrowserTestUtils.startLoadingURIString(privateWindow.gBrowser, url); + info("Wait for page impression."); + await waitForPageWithAdImpressions(); + + info("Close private window."); + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(privateWindow); + + info("Load SERP in non-private window."); + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + info("Wait for page impression."); + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "true", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "true", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Clean-up. + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js new file mode 100644 index 0000000000..5f2afcf6fc --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js @@ -0,0 +1,329 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * When Remote Settings receives an update to search-telemetry-v2, we should + * trigger an update within SearchSERPTelemetry and SearchSERPTelemetryChild + * without requiring a user to restart their browser. + */ + +requestLongerTimeout(5); + +ChromeUtils.defineESModuleGetters(this, { + ADLINK_CHECK_TIMEOUT_MS: "resource:///modules/SearchSERPTelemetry.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SEARCH_TELEMETRY_SHARED: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TELEMETRY_SETTINGS_KEY: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + nonAdsLinkRegexps: [], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +const TEST_PROVIDER_BROKEN_VARIANT = [ + { + ...TEST_PROVIDER_INFO[0], + queryParamNames: ["foo"], + }, +]; + +const RECORDS = { + current: TEST_PROVIDER_INFO, + created: [], + updated: TEST_PROVIDER_INFO, + deleted: [], +}; + +const BROKEN_VARIANT_RECORDS = { + current: TEST_PROVIDER_BROKEN_VARIANT, + created: [], + updated: TEST_PROVIDER_BROKEN_VARIANT, + deleted: [], +}; + +const client = RemoteSettings(TELEMETRY_SETTINGS_KEY); +const db = client.db; +let record = TEST_PROVIDER_INFO[0]; + +async function updateClientWithRecords(records) { + let promise = TestUtils.topicObserved("search-telemetry-v2-synced"); + + await client.emit("sync", { data: records }); + + info("Wait for SearchSERPTelemetry to update."); + await promise; +} + +add_setup(async function () { + // Initialize the test with a variant of telemetry that won't trigger an + // impression due to an odd query param name. + SearchSERPTelemetry.overrideSearchTelemetryForTests( + TEST_PROVIDER_BROKEN_VARIANT + ); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.search.serpEventTelemetry.enabled", true], + // Set the IPC count to a small number so that we only have to open + // one additional tab to reuse the same process. + ["dom.ipc.processCount.webIsolated", 1], + ], + }); + + // Shorten the time it takes to examine pages for ads. + Services.ppmm.sharedData.set(SEARCH_TELEMETRY_SHARED.LOAD_TIMEOUT, 500); + Services.ppmm.sharedData.flush(); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + await db.clear(); + await SpecialPowers.popPrefEnv(); + Services.ppmm.sharedData.set( + SEARCH_TELEMETRY_SHARED.LOAD_TIMEOUT, + ADLINK_CHECK_TIMEOUT_MS + ); + Services.ppmm.sharedData.flush(); + }); +}); + +add_task(async function update_telemetry_tab_already_open() { + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetryAd.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + info("Wait a brief amount of time for a possible SERP impression."); + await waitForIdle(); + + info("Assert no impressions are found."); + assertSERPTelemetry([]); + + info("Update search-telemetry-v2 with a matching queryParamName."); + await updateClientWithRecords(RECORDS); + + info("Reload page."); + gBrowser.reload(); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "reload", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Change search-telemetry-v2 back to the broken variant so that the next + // test can check updating the collection while no tabs are open results + // in a SERP check. + info("Update search-telemetry-v2 with non-matching queryParamName."); + await updateClientWithRecords(BROKEN_VARIANT_RECORDS); + + info("Remove tab and reset telemetry."); + await BrowserTestUtils.removeTab(tab); + resetTelemetry(); +}); + +add_task(async function update_telemetry_tab_closed() { + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetryAd.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + + info("Wait a brief amount of time for a possible SERP impression."); + await waitForIdle(); + + info("Assert no impressions are found."); + assertSERPTelemetry([]); + + info("Remove tab."); + await BrowserTestUtils.removeTab(tab); + + info("Update search-telemetry-v2 with a matching queryParamName."); + await updateClientWithRecords(RECORDS); + + info("Load SERP in a new tab."); + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + info("Update search-telemetry-v2 with non-matching queryParamName."); + await updateClientWithRecords(BROKEN_VARIANT_RECORDS); + + info("Remove tab and reset telemetry."); + await BrowserTestUtils.removeTab(tab); + resetTelemetry(); +}); + +add_task(async function update_telemetry_multiple_tabs() { + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetryAd.html"); + + let tabs = []; + for (let index = 0; index < 5; ++index) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + tabs.push(tab); + } + + info("Wait a brief amount of time for a possible SERP impression."); + await waitForIdle(); + + info("Assert no impressions are found."); + assertSERPTelemetry([]); + + info("Update search-telemetry-v2 with a matching queryParamName."); + await updateClientWithRecords(RECORDS); + + for (let tab of tabs) { + await BrowserTestUtils.switchTab(gBrowser, tab); + gBrowser.reload(); + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "reload", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + await BrowserTestUtils.removeTab(tab); + resetTelemetry(); + } + + info("Update search-telemetry-v2 with non-matching queryParamName."); + await updateClientWithRecords(BROKEN_VARIANT_RECORDS); +}); + +add_task(async function update_telemetry_multiple_processes_and_tabs() { + await SpecialPowers.pushPrefEnv({ + set: [ + // Set the IPC count to a higher number to allow for multiple processes + // for the same domain to be available. + ["dom.ipc.processCount.webIsolated", 4], + ], + }); + + info("Load SERP in a new tab."); + let url = getSERPUrl("searchTelemetryAd.html"); + + let tabs = []; + for (let index = 0; index < 8; ++index) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + tabs.push(tab); + } + + info("Wait a brief amount of time for a possible SERP impression."); + await waitForIdle(); + + info("Assert no impressions are found."); + assertSERPTelemetry([]); + + info("Update search-telemetry-v2 with a matching queryParamName."); + await updateClientWithRecords(RECORDS); + + for (let tab of tabs) { + await BrowserTestUtils.switchTab(gBrowser, tab); + gBrowser.reload(); + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "reload", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); + resetTelemetry(); + } + + info("Update search-telemetry-v2 with non-matching queryParamName."); + await updateClientWithRecords(BROKEN_VARIANT_RECORDS); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js new file mode 100644 index 0000000000..b9f85aaefa --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js @@ -0,0 +1,442 @@ +"use strict"; + +const SCALAR_SEARCHBAR = "browser.engagement.navigation.searchbar"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +let suggestionEngine; + +function checkHistogramResults(resultIndexes, expected, histogram) { + for (let [i, val] of Object.entries(resultIndexes.values)) { + if (i == expected) { + Assert.equal( + val, + 1, + `expected counts should match for ${histogram} index ${i}` + ); + } else { + Assert.equal( + !!val, + false, + `unexpected counts should be zero for ${histogram} index ${i}` + ); + } + } +} + +/** + * Click one of the entries in the search suggestion popup. + * + * @param {string} entryName + * The name of the elemet to click on. + * @param {object} [clickOptions] + * The options to use for the click. + */ +function clickSearchbarSuggestion(entryName, clickOptions = {}) { + let richlistbox = BrowserSearch.searchBar.textbox.popup.richlistbox; + let richlistitem = Array.prototype.find.call( + richlistbox.children, + item => item.getAttribute("ac-value") == entryName + ); + + // Make sure the suggestion is visible and simulate the click. + richlistbox.ensureElementIsVisible(richlistitem); + EventUtils.synthesizeMouseAtCenter(richlistitem, clickOptions); +} + +add_setup(async function () { + await gCUITestUtils.addSearchBar(); + const url = getRootDirectory(gTestPath) + "telemetrySearchSuggestions.xml"; + suggestionEngine = await SearchTestUtils.promiseNewSearchEngine({ url }); + + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); + + // Create two new search engines. Mark one as the default engine, so + // the test don't crash. We need to engines for this test as the searchbar + // doesn't display the default search engine among the one-off engines. + await SearchTestUtils.installSearchExtension( + { + name: "MozSearch", + keyword: "mozalias", + }, + { setAsDefault: true } + ); + await SearchTestUtils.installSearchExtension({ + name: "MozSearch2", + keyword: "mozalias2", + }); + + // Move the second engine at the beginning of the one-off list. + let engineOneOff = Services.search.getEngineByName("MozSearch2"); + await Services.search.moveEngine(engineOneOff, 0); + + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + // Enable event recording for the events tested here. + Services.telemetry.setEventRecordingEnabled("navigation", true); + + registerCleanupFunction(async function () { + Services.telemetry.canRecordExtended = oldCanRecord; + Services.telemetry.setEventRecordingEnabled("navigation", false); + }); +}); + +add_task(async function test_plainQuery() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Simulate entering a simple search."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("simple query"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHBAR, + "search_enter", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch.searchbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "searchbar", + value: "enter", + extra: { engine: "other-MozSearch" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Check the histograms as well. + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the first result, a one-off button, and the Return +// (Enter) key. +add_task(async function test_oneOff_enter() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Perform a one-off search using the first engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("query"); + + info("Pressing Alt+Down to highlight the first one off engine."); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHBAR, + "search_oneoff", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + "other-MozSearch2.searchbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "searchbar", + value: "oneoff", + extra: { engine: "other-MozSearch2" }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Check the histograms as well. + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enter, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using the second result, a one-off button, and the Return +// (Enter) key. This only tests the FX_SEARCHBAR_SELECTED_RESULT_METHOD +// histogram since test_oneOff_enter covers everything else. +add_task(async function test_oneOff_enterSelection() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("query"); + + info( + "Select the second result, press Alt+Down to take us to the first one-off engine." + ); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserTestUtils.removeTab(tab); +}); + +// Performs a search using a click on a one-off button. This only tests the +// FX_SEARCHBAR_SELECTED_RESULT_METHOD histogram since test_oneOff_enter covers +// everything else. +add_task(async function test_oneOff_click() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let popup = await searchInSearchbar("query"); + info("Click the first one-off button."); + popup.oneOffButtons.getSelectableButtons(false)[0].click(); + await p; + + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + BrowserTestUtils.removeTab(tab); +}); + +async function checkSuggestionClick(clickOptions, waitForActionFn) { + // Let's reset the counts. + Services.telemetry.clearScalars(); + Services.telemetry.clearEvents(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + let search_hist = + TelemetryTestUtils.getAndClearKeyedHistogram("SEARCH_COUNTS"); + + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Perform a one-off search using the first engine."); + let p = waitForActionFn(tab); + await searchInSearchbar("query"); + info("Clicking the searchbar suggestion."); + clickSearchbarSuggestion("queryfoo", clickOptions); + await p; + + // Check if the scalars contain the expected values. + const scalars = TelemetryTestUtils.getProcessScalars("parent", true, false); + TelemetryTestUtils.assertKeyedScalar( + scalars, + SCALAR_SEARCHBAR, + "search_suggestion", + 1 + ); + Assert.equal( + Object.keys(scalars[SCALAR_SEARCHBAR]).length, + 1, + "This search must only increment one entry in the scalar." + ); + + // Make sure SEARCH_COUNTS contains identical values. + let searchEngineId = "other-" + suggestionEngine.name; + TelemetryTestUtils.assertKeyedHistogramSum( + search_hist, + searchEngineId + ".searchbar", + 1 + ); + + // Also check events. + TelemetryTestUtils.assertEvents( + [ + { + object: "searchbar", + value: "suggestion", + extra: { engine: searchEngineId }, + }, + ], + { category: "navigation", method: "search" } + ); + + // Check the histograms as well. + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.click, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserTestUtils.removeTab(tab); +} + +// Clicks the first suggestion offered by the test search engine. +add_task(async function test_suggestion_click() { + await checkSuggestionClick({}, tab => { + return BrowserTestUtils.browserLoaded(tab.linkedBrowser); + }); +}); + +add_task(async function test_suggestion_middle_click() { + let openedTab; + await checkSuggestionClick({ button: 1 }, () => { + return BrowserTestUtils.waitForNewTab( + gBrowser, + // eslint-disable-next-line @microsoft/sdl/no-insecure-url + "http://example.com/" + ).then(tab => (openedTab = tab)); + }); + BrowserTestUtils.removeTab(openedTab); +}); + +// Selects and presses the Return (Enter) key on the first suggestion offered by +// the test search engine. This only tests the +// FX_SEARCHBAR_SELECTED_RESULT_METHOD histogram since test_suggestion_click +// covers everything else. +add_task(async function test_suggestion_enterSelection() { + // Let's reset the counts. + Services.telemetry.clearScalars(); + let resultMethodHist = TelemetryTestUtils.getAndClearHistogram( + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + let previousEngine = await Services.search.getDefault(); + await Services.search.setDefault( + suggestionEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + info("Type a query. Suggestions should be generated by the test engine."); + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await searchInSearchbar("query"); + info("Select the second result and press Return."); + EventUtils.synthesizeKey("KEY_ArrowDown"); + EventUtils.synthesizeKey("KEY_Enter"); + await p; + + let resultMethods = resultMethodHist.snapshot(); + checkHistogramResults( + resultMethods, + UrlbarTestUtils.SELECTED_RESULT_METHODS.enterSelection, + "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + + await Services.search.setDefault( + previousEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js new file mode 100644 index 0000000000..e2352b53f4 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js @@ -0,0 +1,143 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check the existence of a shopping tab and navigation to a shopping page. + * Most existing tests don't include shopping tabs, so this explicitly loads a + * page with a shopping tab and clicks on it. + */ + +"use strict"; + +// The setup for each test is the same, the only differences are the various +// permutations of the search tests. +const BASE_TEST_PROVIDER = { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + extraAdServersRegexps: [/^https:\/\/example\.org\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], +}; + +const TEST_PROVIDER_INFO_1 = [ + { + ...BASE_TEST_PROVIDER, + shoppingTab: { + selector: "nav a", + regexp: "&page=shopping&", + inspectRegexpInSERP: true, + }, + }, +]; + +const TEST_PROVIDER_INFO_2 = [ + { + ...BASE_TEST_PROVIDER, + shoppingTab: { + selector: "nav a#shopping", + regexp: "&page=shopping&", + inspectRegexpInSERP: false, + }, + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO_1); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +async function loadSerpAndClickShoppingTab(page) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl(page) + ); + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#shopping", {}, tab.linkedBrowser); + await pageLoadPromise; + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "true", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.SHOPPING_TAB, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_inspect_shopping_tab_regexp_on_serp() { + resetTelemetry(); + await loadSerpAndClickShoppingTab("searchTelemetryAd_shopping.html"); +}); + +add_task(async function test_no_inspect_shopping_tab_regexp_on_serp() { + resetTelemetry(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO_2); + await waitForIdle(); + await loadSerpAndClickShoppingTab("searchTelemetryAd_shopping.html"); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js new file mode 100644 index 0000000000..7fa66a1adf --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js @@ -0,0 +1,349 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and link clicking. + * + * NOTE: As this test file is already fairly long-running, adding to this file + * will likely cause timeout errors with test-verify jobs on Treeherder. + * Therefore, please do not add further tasks to this file. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +/** + * Returns the index of the first search suggestion in the urlbar results. + * + * @returns {number} An index, or -1 if there are no search suggestions. + */ +async function getFirstSuggestionIndex() { + const matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + return i; + } + } + return -1; +} + +SearchTestUtils.init(this); + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + true, + ], + // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.org/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + await gCUITestUtils.addSearchBar(); + + registerCleanupFunction(async () => { + gCUITestUtils.removeSearchBar(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +async function track_ad_click( + expectedHistogramSource, + expectedScalarSource, + searchAdsFn, + cleanupFn +) { + searchCounts.clear(); + Services.telemetry.clearScalars(); + + let expectedContentScalarKey = "example:tagged:ff"; + let expectedScalarKey = "example:tagged"; + let expectedHistogramSAPSourceKey = `other-Example.${expectedHistogramSource}`; + let expectedContentScalar = `browser.search.content.${expectedScalarSource}`; + let expectedWithAdsScalar = `browser.search.withads.${expectedScalarSource}`; + let expectedAdClicksScalar = `browser.search.adclicks.${expectedScalarSource}`; + + let adImpressionPromise = waitForPageWithAdImpressions(); + let tab = await searchAdsFn(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + } + ); + + await adImpressionPromise; + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + [expectedAdClicksScalar]: { [expectedScalarKey]: 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: expectedScalarSource, + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await cleanupFn(); + + Services.fog.testResetFOG(); +} + +add_task(async function test_source_urlbar() { + let tab; + await track_ad_click( + "urlbar", + "urlbar", + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_urlbar_handoff() { + let tab; + await track_ad_click( + "urlbar-handoff", + "urlbar_handoff", + async () => { + Services.fog.testResetFOG(); + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, "about:newtab"); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, "about:newtab"); + + info("Focus on search input in newtab content"); + await BrowserTestUtils.synthesizeMouseAtCenter( + ".fake-editable", + {}, + tab.linkedBrowser + ); + + info("Get suggestions"); + for (const c of "searchSuggestion".split("")) { + EventUtils.synthesizeKey(c); + /* eslint-disable mozilla/no-arbitrary-setTimeout */ + await new Promise(r => setTimeout(r, 50)); + } + await TestUtils.waitForCondition(async () => { + const index = await getFirstSuggestionIndex(); + return index >= 0; + }, "Wait until suggestions are ready"); + + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + const onLoaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await onLoaded; + + return tab; + }, + async () => { + const issueRecords = Glean.newtabSearch.issued.testGetValue(); + Assert.ok(!!issueRecords, "Must have recorded a search issuance"); + Assert.equal(issueRecords.length, 1, "One search, one event"); + const newtabVisitId = issueRecords[0].extra.newtab_visit_id; + Assert.ok(!!newtabVisitId, "Must have a visit id"); + Assert.deepEqual( + { + // Yes, this is tautological. But I want to use deepEqual. + newtab_visit_id: newtabVisitId, + search_access_point: "urlbar_handoff", + telemetry_id: "other-Example", + }, + issueRecords[0].extra, + "Must have recorded the expected information." + ); + const impRecords = Glean.newtabSearchAd.impression.testGetValue(); + Assert.equal(impRecords.length, 1, "One impression, one event."); + Assert.deepEqual( + { + newtab_visit_id: newtabVisitId, + search_access_point: "urlbar_handoff", + telemetry_id: "example", + is_tagged: "true", + is_follow_on: "false", + }, + impRecords[0].extra, + "Must have recorded the expected information." + ); + const clickRecords = Glean.newtabSearchAd.click.testGetValue(); + Assert.equal(clickRecords.length, 1, "One click, one event."); + Assert.deepEqual( + { + newtab_visit_id: newtabVisitId, + search_access_point: "urlbar_handoff", + telemetry_id: "example", + is_tagged: "true", + is_follow_on: "false", + }, + clickRecords[0].extra, + "Must have recorded the expected information." + ); + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_searchbar() { + let tab; + await track_ad_click( + "searchbar", + "searchbar", + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let sb = BrowserSearch.searchBar; + // Write the search query in the searchbar. + sb.focus(); + sb.value = "searchSuggestion"; + sb.textbox.controller.startSearch("searchSuggestion"); + // Wait for the popup to show. + await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown"); + // And then for the search to complete. + await BrowserTestUtils.waitForCondition( + () => + sb.textbox.controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH, + "The search in the searchbar must complete." + ); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + EventUtils.synthesizeKey("KEY_Enter"); + await loadPromise; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_system() { + let tab; + await track_ad_click( + "system", + "system", + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + + // This is not quite the same as calling from the commandline, but close + // enough for this test. + BrowserSearch.loadSearchFromCommandLine( + "searchSuggestion", + false, + Services.scriptSecurityManager.getSystemPrincipal(), + gBrowser.selectedBrowser.csp + ); + + await loadPromise; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + } + ); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js new file mode 100644 index 0000000000..a313c75ac7 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js @@ -0,0 +1,225 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and link + * clicking on about pages. + * + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +/** + * Returns the index of the first search suggestion in the urlbar results. + * + * @returns {number} An index, or -1 if there are no search suggestions. + */ +async function getFirstSuggestionIndex() { + const matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + return i; + } + } + return -1; +} + +SearchTestUtils.init(this); + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + true, + ], + // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.org/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + await gCUITestUtils.addSearchBar(); + + registerCleanupFunction(async () => { + gCUITestUtils.removeSearchBar(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +async function track_ad_click( + expectedHistogramSource, + expectedScalarSource, + searchAdsFn, + cleanupFn +) { + searchCounts.clear(); + Services.telemetry.clearScalars(); + + let expectedContentScalarKey = "example:tagged:ff"; + let expectedScalarKey = "example:tagged"; + let expectedHistogramSAPSourceKey = `other-Example.${expectedHistogramSource}`; + let expectedContentScalar = `browser.search.content.${expectedScalarSource}`; + let expectedWithAdsScalar = `browser.search.withads.${expectedScalarSource}`; + let expectedAdClicksScalar = `browser.search.adclicks.${expectedScalarSource}`; + + let adImpressionPromise = waitForPageWithAdImpressions(); + let tab = await searchAdsFn(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + } + ); + + await adImpressionPromise; + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + [expectedAdClicksScalar]: { [expectedScalarKey]: 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: expectedScalarSource, + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await cleanupFn(); + + Services.fog.testResetFOG(); +} + +async function checkAboutPage( + page, + expectedHistogramSource, + expectedScalarSource +) { + let tab; + await track_ad_click( + expectedHistogramSource, + expectedScalarSource, + async () => { + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, page); + await BrowserTestUtils.browserStopped(tab.linkedBrowser, page); + + // Wait for the full load. + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + false, + ], + ], + }); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + await ContentTaskUtils.waitForCondition( + () => content.wrappedJSObject.gContentSearchController.defaultEngine + ); + }); + + let p = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await typeInSearchField( + tab.linkedBrowser, + "test query", + "newtab-search-text" + ); + await BrowserTestUtils.synthesizeKey("VK_RETURN", {}, tab.linkedBrowser); + await p; + return tab; + }, + async () => { + BrowserTestUtils.removeTab(tab); + await SpecialPowers.popPrefEnv(); + } + ); +} + +add_task(async function test_source_about_home() { + await checkAboutPage("about:home", "abouthome", "about_home"); +}); + +add_task(async function test_source_about_newtab() { + await checkAboutPage("about:newtab", "newtab", "about_newtab"); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js new file mode 100644 index 0000000000..0fd93da30f --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js @@ -0,0 +1,378 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and link clicking. + */ + +"use strict"; + +// Note: example.org is used for the SERP page, and example.com is used to serve +// the ads. This is done to simulate different domains like the real servers. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +function getSERPFollowOnUrl(page) { + return page + "?s=test&abc=ff&a=foo"; +} + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_simple_search_page_visit() { + resetTelemetry(); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: getSERPUrl("searchTelemetry.html"), + }, + async () => { + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + } + ); + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }, + ]); +}); + +add_task(async function test_simple_search_page_visit_telemetry() { + resetTelemetry(); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + /* URL must not be in the cache */ + url: getSERPUrl("searchTelemetry.html") + `&random=${Math.random()}`, + }, + async () => { + let scalars = {}; + const key = "browser.search.data_transferred"; + + await TestUtils.waitForCondition(() => { + scalars = + Services.telemetry.getSnapshotForKeyedScalars("main", false).parent || + {}; + return key in scalars; + }, "should have the expected keyed scalars"); + + const scalar = scalars[key]; + Assert.ok("example" in scalar, "correct telemetry category"); + Assert.notEqual(scalars[key].example, 0, "bandwidth logged"); + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }, + ]); +}); + +add_task(async function test_follow_on_visit() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: getSERPFollowOnUrl(getPageUrl()), + }, + async () => { + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example:tagged:ff": 1, + "example:tagged-follow-on:ff": 1, + }, + } + ); + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE, + }, + }, + ]); +}); + +add_task(async function test_track_ad() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_organic() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html", true) + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:organic:none": 1 }, + "browser.search.withads.unknown": { "example:organic": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_new_window() { + resetTelemetry(); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + let url = getSERPUrl("searchTelemetryAd.html"); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + url + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_track_ad_pages_without_ads() { + // Note: the above tests have already checked a page with no ad-urls. + resetTelemetry(); + + let tabs = []; + + tabs.push( + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetry.html") + ) + ); + await waitForPageWithAdImpressions(); + + tabs.push( + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html") + ) + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 2 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js new file mode 100644 index 0000000000..11d2176563 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js @@ -0,0 +1,373 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests for SearchSERPTelemetry associated with ad clicks. + */ + +"use strict"; + +// Note: example.org is used for the SERP page, and example.com is used to serve +// the ads. This is done to simulate different domains like the real servers. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +async function track_ad_click(testOrganic) { + // Note: the above tests have already checked a page with no ad-urls. + resetTelemetry(); + + let expectedScalarKey = `example:${testOrganic ? "organic" : "tagged"}`; + let expectedContentScalarKey = `example:${ + testOrganic ? "organic:none" : "tagged:ff" + }`; + let tagged = testOrganic ? "false" : "true"; + let partnerCode = testOrganic ? "" : "ff"; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd.html", testOrganic) + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.unknown": { + [expectedScalarKey.replace("sap", "tagged")]: 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.unknown": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Now go back, and click again. + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + gBrowser.goBack(); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + // We've gone back, so we register an extra display & if it is with ads or not. + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.tabhistory": { [expectedContentScalarKey]: 1 }, + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.tabhistory": { [expectedScalarKey]: 1 }, + "browser.search.withads.unknown": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.tabhistory": { [expectedContentScalarKey]: 1 }, + "browser.search.content.unknown": { [expectedContentScalarKey]: 1 }, + "browser.search.withads.tabhistory": { [expectedScalarKey]: 1 }, + "browser.search.withads.unknown": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.tabhistory": { [expectedScalarKey]: 1 }, + "browser.search.adclicks.unknown": { [expectedScalarKey]: 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged, + partner_code: partnerCode, + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_track_ad_click() { + await track_ad_click(false); +}); + +add_task(async function test_track_ad_click_organic() { + await track_ad_click(true); +}); + +add_task(async function test_track_ad_click_with_location_change_other_tab() { + resetTelemetry(); + const url = getSERPUrl("searchTelemetryAd.html"); + let adImpressionPromise = waitForPageWithAdImpressions(); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + await adImpressionPromise; + + const newTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com" + ); + + await BrowserTestUtils.switchTab(gBrowser, tab); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example:tagged": 1 }, + "browser.search.adclicks.unknown": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(newTab); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js new file mode 100644 index 0000000000..3c5e0a464e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js @@ -0,0 +1,173 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests for SearchSERPTelemetry associated with ad links found in data attributes. + */ + +"use strict"; + +// Note: example.org is used for the SERP page, and example.com is used to serve +// the ads. This is done to simulate different domains like the real servers. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example-data-attributes", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetryAd_dataAttributes(?:_none|_href)?.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["xyz"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_track_ad_on_data_attributes() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_dataAttributes.html") + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example-data-attributes:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example-data-attributes:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example-data-attributes", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_on_data_attributes_and_hrefs() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_dataAttributes_href.html") + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example-data-attributes:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example-data-attributes:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example-data-attributes", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_no_ad_on_data_attributes_and_hrefs() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("searchTelemetryAd_dataAttributes_none.html") + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example-data-attributes:tagged:ff": 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example-data-attributes", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js new file mode 100644 index 0000000000..069e13d339 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Tests for SearchSERPTelemetry associated with ad links and load events. + */ + +"use strict"; + +// Note: example.org is used for the SERP page, and example.com is used to serve +// the ads. This is done to simulate different domains like the real servers. +const TEST_PROVIDER_INFO = [ + { + telemetryId: "slow-page-load", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/slow_loading_page_with_ads(_on_load_event)?.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_track_ad_on_DOMContentLoaded() { + resetTelemetry(); + + let observeAdPreviouslyRecorded = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes( + "Ad was previously reported for browser with URI" + ) + ); + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("slow_loading_page_with_ads.html") + ); + + // Observe ad was counted on DOMContentLoaded. + // We do not count the ad again on load. + await observeAdPreviouslyRecorded; + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "slow-page-load:tagged:ff": 1 }, + "browser.search.withads.unknown": { "slow-page-load:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "slow-page-load", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_track_ad_on_load_event() { + resetTelemetry(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + getSERPUrl("slow_loading_page_with_ads_on_load_event.html") + ); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "slow-page-load:tagged:ff": 1 }, + "browser.search.withads.unknown": { "slow-page-load:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "slow-page-load", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js new file mode 100644 index 0000000000..9bff667857 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js @@ -0,0 +1,506 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * SearchSERPTelemetry tests related to in-content sources. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + included: { + parent: { + selector: ".refined-search-buttons", + }, + children: [ + { + selector: "a", + }, + ], + }, + topDown: true, + }, + ], + }, +]; + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [["browser.search.serpEventTelemetry.enabled", true]], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +add_task(async function test_source_opened_in_new_tab_via_middle_click() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#related-in-page", + { button: 1 }, + tab1.linkedBrowser + ); + let tab2 = await tabPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_source_opened_in_new_tab_via_target_blank() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + // Note: the anchor element with id "related-new-tab" has a target=_blank + // attribute. + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#related-new-tab", + {}, + tab1.linkedBrowser + ); + let tab2 = await tabPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_source_opened_in_new_tab_via_context_menu() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a#related-in-page", + { + button: 2, + type: "contextmenu", + }, + tab1.linkedBrowser + ); + await popupShownPromise; + + let openLinkInNewTabMenuItem = contextMenu.querySelector( + "#context-openlinkintab" + ); + contextMenu.activateItem(openLinkInNewTabMenuItem); + + let tab2 = await tabPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "opened_in_new_tab", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); + +add_task( + async function test_source_refinement_button_clicked_no_partner_code() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + adImpressions: [ + { + component: + SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "false", + partner_code: "", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: + SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + } +); + +add_task( + async function test_source_refinement_button_clicked_with_partner_code() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button-with-partner-code", + {}, + tab.linkedBrowser + ); + await pageLoadPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + adImpressions: [ + { + component: + SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: + SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); + } +); + +// When a user opens a refinement button link in a new tab, we want the +// source to be recorded as "follow_on_from_refine_on_SERP", not +// "opened_in_new_tab", since the refinement button click provides greater +// context. +add_task(async function test_refinement_button_vs_opened_in_new_tab() { + resetTelemetry(); + let url = getSERPUrl("searchTelemetryAd_searchbox_with_content.html"); + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await waitForPageWithAdImpressions(); + + let targetUrl = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + "searchTelemetryAd_searchbox_with_content.html?s=test2&abc=ff"; + + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, targetUrl, true); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#refined-search-button-with-partner-code", + { button: 1 }, + tab1.linkedBrowser + ); + let tab2 = await tabPromise; + await waitForPageWithAdImpressions(); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_SERP", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS, + ads_loaded: "1", + ads_visible: "1", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js new file mode 100644 index 0000000000..7ce681701a --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js @@ -0,0 +1,684 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and link clicking. + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?.html/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +/** + * Returns the index of the first search suggestion in the urlbar results. + * + * @returns {number} An index, or -1 if there are no search suggestions. + */ +async function getFirstSuggestionIndex() { + const matchCount = UrlbarTestUtils.getResultCount(window); + for (let i = 0; i < matchCount; i++) { + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i); + if ( + result.type == UrlbarUtils.RESULT_TYPE.SEARCH && + result.searchParams.suggestion + ) { + return i; + } + } + return -1; +} + +SearchTestUtils.init(this); + +let tab; + +add_setup(async function () { + searchCounts.clear(); + Services.telemetry.clearScalars(); + + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.com/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + registerCleanupFunction(async () => { + BrowserTestUtils.removeTab(tab); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +// These tests are consecutive and intentionally build on the results of the +// previous test. + +async function loadSearchPage() { + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + value: "searchSuggestion", + }); + let idx = await getFirstSuggestionIndex(); + Assert.greaterOrEqual(idx, 0, "there should be a first suggestion"); + let loadPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + while (idx--) { + EventUtils.sendKey("down"); + } + EventUtils.sendKey("return"); + await loadPromise; +} + +add_task(async function test_search() { + Services.fog.testResetFOG(); + // Load a page via the address bar. + await loadSearchPage(); + await waitForPageWithAdImpressions(); + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); +}); + +add_task(async function test_reload() { + let adImpressionPromise = waitForPageWithAdImpressions(); + let promise = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + tab.linkedBrowser.reload(); + await promise; + await adImpressionPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.reload": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.reload": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "reload", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.reload": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.reload": { "example:tagged": 1 }, + "browser.search.adclicks.reload": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "reload", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); +}); + +let searchUrl; + +add_task(async function test_fresh_search() { + resetTelemetry(); + + // Load a page via the address bar. + let adImpressionPromise = waitForPageWithAdImpressions(); + await loadSearchPage(); + await adImpressionPromise; + + searchUrl = tab.linkedBrowser.url; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); +}); + +add_task(async function test_click_ad() { + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); +}); + +add_task(async function test_go_back() { + let adImpressionPromise = waitForPageWithAdImpressions(); + let promise = BrowserTestUtils.waitForLocationChange(gBrowser, searchUrl); + tab.linkedBrowser.goBack(); + await promise; + await adImpressionPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.tabhistory": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.tabhistory": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.content.tabhistory": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.withads.tabhistory": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar": { "example:tagged": 1 }, + "browser.search.adclicks.tabhistory": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); +}); + +// Conduct a search from the Urlbar with showSearchTerms enabled. +add_task(async function test_fresh_search_with_urlbar_persisted() { + resetTelemetry(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.showSearchTerms.featureGate", true], + ["browser.urlbar.tipShownCount.searchTip_persist", 999], + ], + }); + + // Load a SERP once in order to show the search term in the Urlbar. + let adImpressionPromise = waitForPageWithAdImpressions(); + await loadSearchPage(); + await adImpressionPromise; + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Do another search from the context of the default SERP. + adImpressionPromise = waitForPageWithAdImpressions(); + await loadSearchPage(); + await adImpressionPromise; + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + "other-Example.urlbar-persisted": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.content.urlbar_persisted": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar_persisted": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar_persisted", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + // Click on an ad. + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + + await pageLoadPromise; + await assertSearchSourcesTelemetry( + { + "other-Example.urlbar": 1, + "other-Example.urlbar-persisted": 1, + }, + { + "browser.search.content.urlbar": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar": { "example:tagged": 1 }, + "browser.search.content.urlbar_persisted": { "example:tagged:ff": 1 }, + "browser.search.withads.urlbar_persisted": { "example:tagged": 1 }, + "browser.search.adclicks.urlbar_persisted": { "example:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: "urlbar_persisted", + shopping_tab_displayed: "false", + is_shopping_page: "false", + is_private: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js new file mode 100644 index 0000000000..f7b22f004b --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js @@ -0,0 +1,219 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * Main tests for SearchSERPTelemetry - general engine visiting and + * link clicking with Web Extensions. + * + */ + +"use strict"; + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetry(?:Ad)?/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + followOnParamNames: ["a"], + extraAdServersRegexps: [/^https:\/\/example\.com\/ad2?/], + components: [ + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + }, +]; + +SearchTestUtils.init(this); + +add_setup(async function () { + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + await waitForIdle(); + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.urlbar.suggest.searches", true], + [ + "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar", + true, + ], + // Ensure to add search suggestion telemetry as search_suggestion not search_formhistory. + ["browser.urlbar.maxHistoricalSearchSuggestions", 0], + ["browser.search.serpEventTelemetry.enabled", true], + ], + }); + // Enable local telemetry recording for the duration of the tests. + let oldCanRecord = Services.telemetry.canRecordExtended; + Services.telemetry.canRecordExtended = true; + + await SearchTestUtils.installSearchExtension( + { + search_url: getPageUrl(true), + search_url_get_params: "s={searchTerms}&abc=ff", + suggest_url: + "https://example.org/browser/browser/components/search/test/browser/searchSuggestionEngine.sjs", + suggest_url_get_params: "query={searchTerms}", + }, + { setAsDefault: true } + ); + + await gCUITestUtils.addSearchBar(); + + registerCleanupFunction(async () => { + gCUITestUtils.removeSearchBar(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + Services.telemetry.canRecordExtended = oldCanRecord; + resetTelemetry(); + }); +}); + +async function track_ad_click( + expectedHistogramSource, + expectedScalarSource, + searchAdsFn, + cleanupFn +) { + searchCounts.clear(); + Services.telemetry.clearScalars(); + + let expectedContentScalarKey = "example:tagged:ff"; + let expectedScalarKey = "example:tagged"; + let expectedHistogramSAPSourceKey = `other-Example.${expectedHistogramSource}`; + let expectedContentScalar = `browser.search.content.${expectedScalarSource}`; + let expectedWithAdsScalar = `browser.search.withads.${expectedScalarSource}`; + let expectedAdClicksScalar = `browser.search.adclicks.${expectedScalarSource}`; + + let adImpressionPromise = waitForPageWithAdImpressions(); + let tab = await searchAdsFn(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + } + ); + + await adImpressionPromise; + + let pageLoadPromise = BrowserTestUtils.waitForLocationChange(gBrowser); + BrowserTestUtils.synthesizeMouseAtCenter("#ad1", {}, tab.linkedBrowser); + await pageLoadPromise; + await promiseWaitForAdLinkCheck(); + + await assertSearchSourcesTelemetry( + { + [expectedHistogramSAPSourceKey]: 1, + }, + { + [expectedContentScalar]: { [expectedContentScalarKey]: 1 }, + [expectedWithAdsScalar]: { [expectedScalarKey]: 1 }, + [expectedAdClicksScalar]: { [expectedScalarKey]: 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example", + tagged: "true", + partner_code: "ff", + source: expectedScalarSource, + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await cleanupFn(); + + Services.fog.testResetFOG(); +} + +add_task(async function test_source_webextension_search() { + /* global browser */ + async function background(SEARCH_TERM) { + // Search with no tabId + browser.search.search({ query: "searchSuggestion", engine: "Example" }); + } + + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + useAddonManager: "temporary", + }); + + let tab; + await track_ad_click( + "webextension", + "webextension", + async () => { + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + + await searchExtension.startup(); + + return (tab = await tabPromise); + }, + async () => { + await searchExtension.unload(); + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_source_webextension_query() { + async function background(SEARCH_TERM) { + // Search with no tabId + browser.search.query({ + text: "searchSuggestion", + disposition: "NEW_TAB", + }); + } + + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + useAddonManager: "temporary", + }); + + let tab; + await track_ad_click( + "webextension", + "webextension", + async () => { + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, null, true); + + await searchExtension.startup(); + + return (tab = await tabPromise); + }, + async () => { + await searchExtension.unload(); + BrowserTestUtils.removeTab(tab); + } + ); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js new file mode 100644 index 0000000000..39270c7e9f --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js @@ -0,0 +1,524 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check SPA in-content interactions (e.g. search box, clicking autosuggest) and + * ensures we're correctly unloading / adding listeners to elements, and + * registering the right engagements for search submission events that could + * change the location of the page. + */ + +"use strict"; + +add_setup(async function () { + await initSinglePageAppTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount.webIsolated", 1]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_content_process_type_search_click_suggestion() { + resetTelemetry(); + + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.clickSearchboxAndType(tab); + await SinglePageAppUtils.clickSuggestion(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 2, + }, + "browser.search.withads.unknown": { + "example1:tagged": 2, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + await BrowserTestUtils.removeTab(tab); +}); + +add_task( + async function test_content_process_type_search_click_related_search() { + resetTelemetry(); + + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.clickSearchboxAndType(tab); + await SinglePageAppUtils.visitRelatedSearch(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 2, + }, + "browser.search.withads.unknown": { + "example1:tagged": 2, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + await BrowserTestUtils.removeTab(tab); + } +); + +add_task(async function test_content_process_engagement() { + resetTelemetry(); + + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.clickSearchbox(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example1:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_content_process_engagement_that_changes_page() { + resetTelemetry(); + + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.clickSuggestion(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 2, + }, + "browser.search.withads.unknown": { + "example1:tagged": 2, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); + +// This is to ensure if the user switches to another search page, we unload +// the listeners, add them back in, and then accurately register the correct +// number of engagements. The engagement target should also be accurate. +add_task( + async function test_in_page_reload_and_content_process_engagement_that_changes_page() { + resetTelemetry(); + + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.visitRelatedSearch(tab); + await SinglePageAppUtils.clickSuggestion(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 3, + }, + "browser.search.withads.unknown": { + "example1:tagged": 3, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.SUBMITTED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "follow_on_from_refine_on_incontent_search", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); + } +); + +// Clicking on another SERP tab and selecting the searchbox shouldn't cause a +// new engagement. +add_task(async function test_unload_listeners_single_tab() { + resetTelemetry(); + + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.clickImagesTab(tab); + await SinglePageAppUtils.clickSearchbox(tab); + await SinglePageAppUtils.clickSuggestionOnImagesTab(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example1:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); + +// Make sure unloading listeners is specific to the tab. +add_task(async function test_unload_listeners_multi_tab() { + resetTelemetry(); + + let tab1 = await SinglePageAppUtils.createTabAndLoadURL(); + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(); + + // Listener should no longer be applicable on tab2 because we're switching + // to tab2. + await SinglePageAppUtils.clickImagesTab(tab2); + await SinglePageAppUtils.clickSearchbox(tab2); + await SinglePageAppUtils.clickSuggestionOnImagesTab(tab2); + + // Click a searchbox on tab1 to verify the listener is still working. + await SinglePageAppUtils.clickSearchbox(tab1); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 2, + }, + "browser.search.withads.unknown": { + "example1:tagged": 2, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js new file mode 100644 index 0000000000..1e44957daa --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js @@ -0,0 +1,529 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check SPA page loads on two different providers that are both SPAs. A sanity + * check to ensure we're categorizing them separately. They differ by having + * different top level domains (.com vs .org). + */ + +"use strict"; + +add_setup(async function () { + await initSinglePageAppTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount.webIsolated", 1]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_load_serps_and_click_organic() { + resetTelemetry(); + + let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders(); + + for (let tab of tabs) { + await SinglePageAppUtils.clickOrganic(tab); + } + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 1, + "example2:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example1:tagged": 1, + "example2:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + ]); + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } +}); + +add_task(async function test_load_serps_and_click_ads() { + resetTelemetry(); + + let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders(); + + for (let tab of tabs) { + await BrowserTestUtils.switchTab(gBrowser, tab); + await SinglePageAppUtils.clickAd(tab); + } + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 1, + "example2:tagged:ff": 1, + }, + "browser.search.withads.unknown": { + "example1:tagged": 1, + "example2:tagged": 1, + }, + "browser.search.adclicks.unknown": { + "example1:tagged": 1, + "example2:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + { + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } +}); + +add_task(async function test_load_serps_and_click_related() { + resetTelemetry(); + + let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders(); + + for (let tab of tabs) { + await BrowserTestUtils.switchTab(gBrowser, tab); + await SinglePageAppUtils.visitRelatedSearch(tab); + } + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 2, + "example2:tagged:ff": 2, + }, + "browser.search.withads.unknown": { + "example1:tagged": 2, + "example2:tagged": 2, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } +}); + +add_task(async function test_load_pages_tabhistory() { + resetTelemetry(); + + let tabs = await SinglePageAppUtils.createTabsWithDifferentProviders(); + + for (let tab of tabs) { + await BrowserTestUtils.switchTab(gBrowser, tab); + await SinglePageAppUtils.visitRelatedSearch(tab); + await SinglePageAppUtils.goBack(tab); + await SinglePageAppUtils.goForward(tab); + } + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 2, + "example2:tagged:ff": 2, + }, + "browser.search.withads.unknown": { + "example1:tagged": 2, + "example2:tagged": 2, + }, + "browser.search.content.tabhistory": { + "example1:tagged:ff": 2, + "example2:tagged:ff": 2, + }, + "browser.search.withads.tabhistory": { + "example1:tagged": 2, + "example2:tagged": 2, + }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + // This is second because it was the second tab created. + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example2", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js new file mode 100644 index 0000000000..478a995e97 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js @@ -0,0 +1,875 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Check SPA page loads on a single provider using multiple tabs. + */ + +"use strict"; + +// Allow more time for Mac machines so they don't time out in verify mode. +if (AppConstants.platform == "macosx") { + requestLongerTimeout(3); +} + +add_setup(async function () { + await initSinglePageAppTest(); + + await SpecialPowers.pushPrefEnv({ + set: [["dom.ipc.processCount.webIsolated", 1]], + }); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +// Deliberately has actions happening after one another to assert that the +// different events are recorded properly. + +// One issue that can occur is if two SERPs have the same search term, if we +// try finding the item for the URL, it might match the wrong item. +// e.g. two tabs search for foobar +// one tab then searches for barfoo +// the other tab that had foobar does another search, but instead of referring +// back to foobar, it looks at barfoo and messes with its state. + +// We use switch tabs to avoid `getBoundsWithoutFlushing` not returning the +// latest visual info, which affects ad visibility counts. +add_task(async function test_load_serps_and_click_related_searches() { + resetTelemetry(); + + let tab1 = await SinglePageAppUtils.createTabAndLoadURL(); + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(); + let tab3 = await SinglePageAppUtils.createTabAndLoadURL(); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab1); + + await BrowserTestUtils.switchTab(gBrowser, tab2); + await SinglePageAppUtils.visitRelatedSearch(tab2); + + await BrowserTestUtils.switchTab(gBrowser, tab3); + await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab3); + + await BrowserTestUtils.switchTab(gBrowser, tab1); + await SinglePageAppUtils.visitRelatedSearch(tab1); + + await BrowserTestUtils.switchTab(gBrowser, tab2); + await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab2); + + await BrowserTestUtils.switchTab(gBrowser, tab3); + await SinglePageAppUtils.visitRelatedSearchWithoutAds(tab3); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 9, + }, + "browser.search.withads.unknown": { + "example1:tagged": 5, + }, + } + ); + + assertSERPTelemetry([ + { + // Tab 1 - Visit a SERP, clicks a related search without ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visits a SERP, clicks a related SERP with ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 3 - Visit a SERP, clicks a related SERP without ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 1 - Visits a related SERP without ads, clicks on a related SERP + // with ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [], + }, + { + // Tab 2 - Visits a related search with ads, clicks a related SERP + // without ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 3 - Visit a SERP without ads, clicks a related SERP without ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [], + }, + { + // Tab 1 - Visit a related SERP with ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a related SERP without ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [], + }, + { + // Tab 3 - Visit a related SERP without ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [], + }, + ]); + + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); + await BrowserTestUtils.removeTab(tab3); +}); + +/** + * The source of the ad click should match the correct tab. + */ +add_task(async function test_different_sources_click_ad() { + resetTelemetry(); + + let tab1 = await SinglePageAppUtils.createTabAndLoadURL(); + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(); + + await SinglePageAppUtils.visitRelatedSearch(tab2); + await SinglePageAppUtils.goBack(tab2); + await SinglePageAppUtils.clickAd(tab2); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 3, + }, + "browser.search.withads.unknown": { + "example1:tagged": 3, + }, + "browser.search.content.tabhistory": { + "example1:tagged:ff": 1, + }, + "browser.search.withads.tabhistory": { + "example1:tagged": 1, + }, + "browser.search.adclicks.tabhistory": { + "example1:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + // Tab 1 - Visit a SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a SERP, click a related SERP with ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a SERP, click back button. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + // Tab 2 - Visit a SERP, click ad button. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_different_sources_click_redirect_ad_in_new_tab() { + resetTelemetry(); + + let tab1 = await SinglePageAppUtils.createTabAndLoadURL(); + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(); + + await SinglePageAppUtils.visitRelatedSearch(tab2); + await SinglePageAppUtils.goBack(tab2); + let tab3 = await SinglePageAppUtils.clickRedirectAdInNewTab(tab2); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 3, + }, + "browser.search.withads.unknown": { + "example1:tagged": 3, + }, + "browser.search.content.tabhistory": { + "example1:tagged:ff": 1, + }, + "browser.search.withads.tabhistory": { + "example1:tagged": 1, + }, + "browser.search.adclicks.tabhistory": { + "example1:tagged": 1, + }, + } + ); + + assertSERPTelemetry([ + { + // Tab 1 - Visit a SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a SERP, click a related SERP with ads. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a SERP, click back button. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + // Tab 2 - Visit a SERP, click ad button. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); + await BrowserTestUtils.removeTab(tab3); +}); + +add_task(async function test_update_query_params_after_search() { + resetTelemetry(); + + let tab1 = await SinglePageAppUtils.createTabAndLoadURL(); + info("Visit a related search so that the URL has an extra query parameter."); + await SinglePageAppUtils.visitRelatedSearch(tab1); + + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 3, + }, + "browser.search.withads.unknown": { + "example1:tagged": 3, + }, + } + ); + + assertSERPTelemetry([ + { + // Tab 1 - Visit a SERP, click on a related SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 1 - Visit a related SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a SERP, click on an ad. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_update_query_params() { + resetTelemetry(); + + // Deliberately use a different search term for the first example, because + // if both tabs have the same search term and a link is clicked that opens a + // new window, we currently can't recover the exact browser. + let tab1 = await SinglePageAppUtils.createTabAndSearch("foo bar"); + info("Visit a related search so that the URL has an extra query parameter."); + await SinglePageAppUtils.visitRelatedSearch(tab1); + + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(); + let newWindow = await SinglePageAppUtils.clickRedirectAdInNewWindow(tab2); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 3, + }, + "browser.search.withads.unknown": { + "example1:tagged": 3, + }, + "browser.search.adclicks.unknown": { "example1:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + // Tab 1 - Visit a SERP, clicked a related SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 1 - Visit a related SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a SERP, click ad opening in a new window. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.closeWindow(newWindow); + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); +}); + +add_task(async function test_update_query_params_multiple_related() { + resetTelemetry(); + + let tab1 = await SinglePageAppUtils.createTabAndSearch("foo bar"); + info("Visit a related search so that the URL has an extra query parameter."); + await SinglePageAppUtils.visitRelatedSearch(tab1); + + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(); + info("Visit a related search so that the URL has an extra query parameter."); + await SinglePageAppUtils.visitRelatedSearch(tab2); + + let newWindow = await SinglePageAppUtils.clickRedirectAdInNewWindow(tab2); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { + "example1:tagged:ff": 4, + }, + "browser.search.withads.unknown": { + "example1:tagged": 4, + }, + "browser.search.adclicks.unknown": { "example1:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + // Tab 1 - Visit a SERP, clicked a related SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 1 - Visit a related SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a SERP, clicked a related SERP. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + // Tab 2 - Visit a related SERP. Click on ad that opens in a new window. + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.closeWindow(newWindow); + await BrowserTestUtils.removeTab(tab1); + await BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js new file mode 100644 index 0000000000..4f85c6cfa1 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js @@ -0,0 +1,661 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * These tests check on SPA page loads in a single tab. + * They also ensure the SinglePageAppUtils method work as expected. + */ + +"use strict"; + +add_setup(async function () { + await initSinglePageAppTest(); + + registerCleanupFunction(async () => { + SearchSERPTelemetry.overrideSearchTelemetryForTests(); + resetTelemetry(); + }); +}); + +add_task(async function test_load_serp() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_and_push_unrelated_state() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + let searchParams = new URL(tab.linkedBrowser.currentURI.spec).searchParams; + + Assert.equal( + searchParams.get("foobar"), + null, + "Query param value for: foobar" + ); + + await SinglePageAppUtils.pushUnrelatedState(tab, { + key: "foobar", + value: "baz", + }); + searchParams = new URL(tab.linkedBrowser.currentURI.spec).searchParams; + Assert.equal( + searchParams.get("foobar"), + "baz", + "Query param value for: foobar" + ); + + // If the SERP adds query parameter unrelated to the search query or the + // query param matching the default results page, we shouldn't record another + // SERP load. + /* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 1000)); + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_and_load_non_serp_tab() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + + await SinglePageAppUtils.clickImagesTab(tab); + // If clicking another tab in a SPA, we shouldn't record another SERP load. + /* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */ + await new Promise(resolve => setTimeout(resolve, 1000)); + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_and_click_ad() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await SinglePageAppUtils.clickAd(tab); + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + "browser.search.adclicks.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_and_click_redirect_ad() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + + await SinglePageAppUtils.clickRedirectAd(tab); + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + "browser.search.adclicks.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_and_click_redirect_ad_in_new_tab() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + + let redirectedTab = await SinglePageAppUtils.clickRedirectAdInNewTab(tab); + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + "browser.search.adclicks.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.removeTab(redirectedTab); +}); + +add_task(async function test_load_serp_click_a_related_search() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.visitRelatedSearch(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 2 }, + "browser.search.withads.unknown": { "example1:tagged": 2 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_click_a_related_search_click_ad() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.visitRelatedSearch(tab); + await SinglePageAppUtils.clickAd(tab); + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 2 }, + "browser.search.withads.unknown": { "example1:tagged": 2 }, + "browser.search.adclicks.unknown": { "example1:tagged": 1 }, + } + ); + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_click_non_serp_tab_click_all() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.clickImagesTab(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 1 }, + "browser.search.withads.unknown": { "example1:tagged": 1 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + info("Click All tab to return to a SERP."); + await SinglePageAppUtils.clickAllTab(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 2 }, + "browser.search.withads.unknown": { "example1:tagged": 2 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_load_serp_and_use_back_and_forward() { + resetTelemetry(); + let tab = await SinglePageAppUtils.createTabAndLoadURL(); + await SinglePageAppUtils.visitRelatedSearch(tab); + await SinglePageAppUtils.goBack(tab); + await SinglePageAppUtils.goForward(tab); + + await assertSearchSourcesTelemetry( + {}, + { + "browser.search.content.unknown": { "example1:tagged:ff": 2 }, + "browser.search.withads.unknown": { "example1:tagged": 2 }, + "browser.search.content.tabhistory": { "example1:tagged:ff": 2 }, + "browser.search.withads.tabhistory": { "example1:tagged": 2 }, + } + ); + + assertSERPTelemetry([ + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + engagements: [ + { + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }, + ], + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "unknown", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + abandonment: { + reason: SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION, + }, + }, + { + impression: { + provider: "example1", + tagged: "true", + partner_code: "ff", + source: "tabhistory", + is_shopping_page: "false", + is_private: "false", + shopping_tab_displayed: "false", + }, + adImpressions: [ + { + component: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + ads_loaded: "2", + ads_visible: "2", + ads_hidden: "0", + }, + ], + }, + ]); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/search/test/browser/telemetry/cacheable.html b/browser/components/search/test/browser/telemetry/cacheable.html new file mode 100644 index 0000000000..8aac4a0f16 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/cacheable.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Cacheable Page</title> +</head> +<body> + <p>This page is cacheable.</p> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/cacheable.html^headers^ b/browser/components/search/test/browser/telemetry/cacheable.html^headers^ new file mode 100644 index 0000000000..6f34caa8f2 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/cacheable.html^headers^ @@ -0,0 +1 @@ +Cache-Control: max-age=3600 diff --git a/browser/components/search/test/browser/telemetry/domain_category_mappings.json b/browser/components/search/test/browser/telemetry/domain_category_mappings.json new file mode 100644 index 0000000000..2f8d0d2af2 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/domain_category_mappings.json @@ -0,0 +1,8 @@ +{ + "DqNorjpE3CBY9OZh0wf1uA==": [2, 90], + "kpuib0kvhtSp1moICEmGWg==": [2, 95], + "+5WbbjV3Nmxp0mBZODcJWg==": [2, 78, 4, 10], + "OIHlWZ/yMyTHHuY78AV9VQ==": [3, 90], + "r1hDZinn+oNrQjabn8IB9w==": [4, 90], + "AtlIam7nqWvzFzTGkYI01w==": [4, 90] +} diff --git a/browser/components/search/test/browser/telemetry/head-spa.js b/browser/components/search/test/browser/telemetry/head-spa.js new file mode 100644 index 0000000000..2718dbb9ff --- /dev/null +++ b/browser/components/search/test/browser/telemetry/head-spa.js @@ -0,0 +1,259 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Helpers to simulate the use of a single page application. + */ + +/* import-globals-from head.js */ + +/** + * Used to control the SPA SERP. + */ +class SinglePageAppUtils { + static async clickAd(tab) { + info("Clicking ad."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad", + {}, + tab.linkedBrowser + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + } + + static async clickAllTab(tab) { + info("Click All tab to return to a SERP."); + let adsPromise = TestUtils.topicObserved( + "reported-page-with-ad-impressions" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#all", + {}, + tab.linkedBrowser + ); + await adsPromise; + } + + static async clickImagesTab(tab) { + info("Click images tab."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#images", + {}, + tab.linkedBrowser + ); + info("Wait a brief amount of time."); + // There's no obvious way to know we shouldn't expect a SERP impression, so + // we wait roughly the amount of time it would take for extracting ads to + // take. + await promiseWaitForAdLinkCheck(); + } + + static async clickOrganic(tab) { + info("Clicking organic result."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#organic", + {}, + tab.linkedBrowser + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + } + + static async clickRedirectAd(tab) { + info("Clicking redirect ad."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad-redirect", + {}, + tab.linkedBrowser + ); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + } + + static async clickRedirectAdInNewTab(tab) { + info("Clicking redirect ad in new tab."); + let tabPromise = BrowserTestUtils.waitForNewTab(tab.ownerGlobal.gBrowser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad-redirect", + { button: 1 }, + tab.linkedBrowser + ); + let redirectedTab = await tabPromise; + return redirectedTab; + } + + static async clickRedirectAdInNewWindow(tab) { + let contextMenu = tab.linkedBrowser.ownerGlobal.document.getElementById( + "contentAreaContextMenu" + ); + let contextMenuPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + info("Open context menu."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#ad-redirect", + { type: "contextmenu", button: 2 }, + tab.linkedBrowser + ); + await contextMenuPromise; + + let newWindowPromise = BrowserTestUtils.waitForNewWindow({ + url: "https://example.com/hello_world", + }); + let openLinkInNewWindow = contextMenu.querySelector("#context-openlink"); + info("Click on Open Link in New Window"); + contextMenu.activateItem(openLinkInNewWindow); + return await newWindowPromise; + } + + static async clickSearchbox(tab) { + info("Clicking searchbox."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#searchbox", + {}, + tab.linkedBrowser + ); + await waitForIdle(); + } + + static async clickSearchboxAndType(tab, str = "hello world") { + await SinglePageAppUtils.clickSearchbox(tab); + info(`Type ${str} in searchbox.`); + for (let char of str) { + await BrowserTestUtils.sendChar(char, tab.linkedBrowser); + } + await waitForIdle(); + } + + static async clickSuggestion(tab) { + info("Clicking the first suggestion."); + let adsPromise = TestUtils.topicObserved( + "reported-page-with-ad-impressions" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#searchbox-suggestions div", + {}, + tab.linkedBrowser + ); + await adsPromise; + } + + static async clickSuggestionOnImagesTab(tab) { + info("Clicking the first suggestion on images tab."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#searchbox-suggestions div", + {}, + tab.linkedBrowser + ); + await promiseWaitForAdLinkCheck(); + } + + static async createTabAndLoadURL( + url = new URL(getSERPUrl("searchTelemetrySinglePageApp.html")) + ) { + info("Load SERP."); + let adsPromise = TestUtils.topicObserved( + "reported-page-with-ad-impressions" + ); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url.href); + await adsPromise; + return tab; + } + + static async createTabAndSearch(searchTerm = "test") { + info("Load SERP."); + let adsPromise = TestUtils.topicObserved( + "reported-page-with-ad-impressions" + ); + let url = new URL(getSERPUrl("searchTelemetrySinglePageApp.html")); + url.searchParams.set("s", searchTerm); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url.href); + await adsPromise; + return tab; + } + + static async createTabsWithDifferentProviders() { + let url1 = new URL(getSERPUrl("searchTelemetrySinglePageApp.html")); + let tab1 = await SinglePageAppUtils.createTabAndLoadURL(url1); + + let url2 = new URL( + getAlternateSERPUrl("searchTelemetrySinglePageApp.html") + ); + let tab2 = await SinglePageAppUtils.createTabAndLoadURL(url2); + + return [tab1, tab2]; + } + + static async goBack(tab) { + info("Go back to SERP ads."); + let promise = TestUtils.topicObserved("reported-page-with-ad-impressions"); + tab.linkedBrowser.goBack(); + await promise; + } + + static async goBackToPageWithoutAds(tab) { + info("Go back to SERP without ads."); + tab.linkedBrowser.goBack(); + await new Promise(resolve => setTimeout(resolve, 200)); + } + + static async goForward(tab) { + info("Go forward to SERP ads."); + let promise = TestUtils.topicObserved("reported-page-with-ad-impressions"); + tab.linkedBrowser.goForward(); + await promise; + } + + static async goForwardToPageWithoutAds(tab) { + info("Go forward to SERP without ads."); + tab.linkedBrowser.goForward(); + await new Promise(resolve => setTimeout(resolve, 200)); + } + + static async pushUnrelatedState(tab, { key = "foobar", value = "baz" } = {}) { + info(`Pushing ${key}=${value} to the list of query parameters in URL.`); + await SpecialPowers.spawn( + tab.linkedBrowser, + [key, value], + async function (contentKey, contentValue) { + let url = new URL(content.window.location.href); + url.searchParams.set(contentKey, contentValue); + content.history.pushState({}, "", url); + } + ); + } + + static async visitRelatedSearch(tab) { + let adsPromise = TestUtils.topicObserved( + "reported-page-with-ad-impressions" + ); + info("Clicking a related search with an ad."); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-search", + {}, + tab.linkedBrowser + ); + await adsPromise; + } + + static async visitRelatedSearchWithoutAds(tab) { + info("Clicking a related search without ads."); + let adsPromise = TestUtils.topicObserved( + "reported-page-with-ad-impressions" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#related-search-without-ads", + {}, + tab.linkedBrowser + ); + await adsPromise; + } +} + +function getAlternateSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} diff --git a/browser/components/search/test/browser/telemetry/head.js b/browser/components/search/test/browser/telemetry/head.js new file mode 100644 index 0000000000..416451e400 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/head.js @@ -0,0 +1,621 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + ADLINK_CHECK_TIMEOUT_MS: + "resource:///actors/SearchSERPTelemetryChild.sys.mjs", + CustomizableUITestUtils: + "resource://testing-common/CustomizableUITestUtils.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SEARCH_TELEMETRY_SHARED: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + SERPCategorizationRecorder: "resource:///modules/SearchSERPTelemetry.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", + SPA_ADLINK_CHECK_TIMEOUT_MS: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + TELEMETRY_CATEGORIZATION_KEY: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(this, "UrlbarTestUtils", () => { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(this); + return module; +}); + +ChromeUtils.defineLazyGetter(this, "searchCounts", () => { + return Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS"); +}); + +ChromeUtils.defineLazyGetter(this, "SEARCH_AD_CLICK_SCALARS", () => { + const sources = [ + ...BrowserSearchTelemetry.KNOWN_SEARCH_SOURCES.values(), + "unknown", + ]; + return [ + ...sources.map(v => `browser.search.withads.${v}`), + ...sources.map(v => `browser.search.adclicks.${v}`), + ]; +}); + +// For use with categorization. +const APP_MAJOR_VERSION = parseInt(Services.appinfo.version).toString(); +const CHANNEL = SearchUtils.MODIFIED_APP_CHANNEL; +const REGION = Region.home; + +let gCUITestUtils = new CustomizableUITestUtils(window); + +SearchTestUtils.init(this); + +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +// sharedData messages are only passed to the child on idle. Therefore +// we wait for a few idles to try and ensure the messages have been able +// to be passed across and handled. +async function waitForIdle() { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + } +} + +function getPageUrl(useAdPage = false) { + let page = useAdPage ? "searchTelemetryAd.html" : "searchTelemetry.html"; + return `https://example.org/browser/browser/components/search/test/browser/telemetry/${page}`; +} + +function getSERPUrl(page, organic = false) { + let url = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.org" + ) + page; + return `${url}?s=test${organic ? "" : "&abc=ff"}`; +} + +async function typeInSearchField(browser, text, fieldName) { + await SpecialPowers.spawn( + browser, + [[fieldName, text]], + async function ([contentFieldName, contentText]) { + // Put the focus on the search box. + let searchInput = content.document.getElementById(contentFieldName); + searchInput.focus(); + searchInput.value = contentText; + } + ); +} + +async function searchInSearchbar(inputText, win = window) { + await new Promise(r => waitForFocus(r, win)); + let sb = win.BrowserSearch.searchBar; + // Write the search query in the searchbar. + sb.focus(); + sb.value = inputText; + sb.textbox.controller.startSearch(inputText); + // Wait for the popup to show. + await BrowserTestUtils.waitForEvent(sb.textbox.popup, "popupshown"); + // And then for the search to complete. + await TestUtils.waitForCondition( + () => + sb.textbox.controller.searchStatus >= + Ci.nsIAutoCompleteController.STATUS_COMPLETE_NO_MATCH, + "The search in the searchbar must complete." + ); + return sb.textbox.popup; +} + +// Ad links are processed after a small delay. We need to allow tests to wait +// for that before checking telemetry, otherwise the received values may be +// too small in some cases. +function promiseWaitForAdLinkCheck() { + return new Promise(resolve => + /* eslint-disable-next-line mozilla/no-arbitrary-setTimeout */ + setTimeout(resolve, ADLINK_CHECK_TIMEOUT_MS) + ); +} + +async function assertSearchSourcesTelemetry( + expectedHistograms, + expectedScalars +) { + let histSnapshot = {}; + let scalars = {}; + + // This used to rely on the implied 100ms initial timer of + // TestUtils.waitForCondition. See bug 1515466. + await new Promise(resolve => setTimeout(resolve, 100)); + + await TestUtils.waitForCondition(() => { + histSnapshot = searchCounts.snapshot(); + return ( + Object.getOwnPropertyNames(histSnapshot).length == + Object.getOwnPropertyNames(expectedHistograms).length + ); + }, "should have the correct number of histograms"); + + if (Object.entries(expectedScalars).length) { + await TestUtils.waitForCondition(() => { + scalars = + Services.telemetry.getSnapshotForKeyedScalars("main", false).parent || + {}; + return Object.getOwnPropertyNames(expectedScalars).every( + scalar => scalar in scalars + ); + }, "should have the expected keyed scalars"); + } + + Assert.equal( + Object.getOwnPropertyNames(histSnapshot).length, + Object.getOwnPropertyNames(expectedHistograms).length, + "Should only have one key" + ); + + for (let [key, value] of Object.entries(expectedHistograms)) { + Assert.ok( + key in histSnapshot, + `Histogram should have the expected key: ${key}` + ); + Assert.equal( + histSnapshot[key].sum, + value, + `Should have counted the correct number of visits for ${key}` + ); + } + + for (let [name, value] of Object.entries(expectedScalars)) { + Assert.ok(name in scalars, `Scalar ${name} should have been added.`); + Assert.deepEqual( + scalars[name], + value, + `Should have counted the correct number of visits for ${name}` + ); + } + + for (let name of SEARCH_AD_CLICK_SCALARS) { + Assert.equal( + name in scalars, + name in expectedScalars, + `Should have matched ${name} in scalars and expectedScalars` + ); + } +} + +function resetTelemetry() { + // TODO Bug 1868476: Replace when we're using Glean telemetry. + fakeTelemetryStorage = []; + searchCounts.clear(); + Services.telemetry.clearScalars(); + Services.fog.testResetFOG(); +} + +/** + * First checks that we get the correct number of recorded Glean impression events + * and the recorded Glean impression events have the correct keys and values. + * + * Then it checks that there are the the correct engagement events associated with the + * impression events. + * + * @param {Array} expectedEvents The expected impression events whose keys and + * values we use to validate the recorded Glean impression events. + */ +function assertSERPTelemetry(expectedEvents) { + // A single test might run assertImpressionEvents more than once + // so the Set needs to be cleared or else the impression event + // check will throw. + const impressionIdsSet = new Set(); + + let recordedImpressions = Glean.serp.impression.testGetValue() ?? []; + + Assert.equal( + recordedImpressions.length, + expectedEvents.length, + "Number of impressions matches expected events." + ); + + // Assert the impression events. + for (let [idx, expectedEvent] of expectedEvents.entries()) { + let impressionId = recordedImpressions[idx].extra.impression_id; + Assert.ok( + UUID_REGEX.test(impressionId), + "Impression has an impression_id with a valid UUID." + ); + + Assert.ok( + !impressionIdsSet.has(impressionId), + "Impression has a unique impression_id." + ); + + impressionIdsSet.add(impressionId); + + // If we want to use deepEqual checks, we have to add the impressionId + // to each impression since they are randomly generated at runtime. + expectedEvent.impression.impression_id = impressionId; + + Assert.deepEqual( + recordedImpressions[idx].extra, + expectedEvent.impression, + "Matching SERP impression values." + ); + + // Once the impression check is sufficient, add the impression_id to + // each of the expected engagements, ad impressions, and abandonments for + // deep equal checks. + if (expectedEvent.engagements) { + for (let expectedEngagment of expectedEvent.engagements) { + expectedEngagment.impression_id = impressionId; + } + } + if (expectedEvent.adImpressions) { + for (let adImpression of expectedEvent.adImpressions) { + adImpression.impression_id = impressionId; + } + } + if (expectedEvent.abandonment) { + expectedEvent.abandonment.impression_id = impressionId; + } + } + + // Group engagement events into separate array fetchable by their + // impression_id. + let recordedEngagements = Glean.serp.engagement.testGetValue() ?? []; + let idToEngagements = new Map(); + let totalExpectedEngagements = 0; + + for (let recordedEngagement of recordedEngagements) { + let impressionId = recordedEngagement.extra.impression_id; + Assert.ok(impressionId, "Engagement event has impression_id."); + + let arr = idToEngagements.get(impressionId) ?? []; + arr.push(recordedEngagement.extra); + + idToEngagements.set(impressionId, arr); + } + + // Assert the engagement events. + for (let expectedEvent of expectedEvents) { + let impressionId = expectedEvent.impression.impression_id; + let expectedEngagements = expectedEvent.engagements; + if (expectedEngagements) { + let recorded = idToEngagements.get(impressionId); + Assert.deepEqual( + recorded, + expectedEngagements, + "Matching engagement value." + ); + totalExpectedEngagements += expectedEngagements.length; + } + } + + Assert.equal( + recordedEngagements.length, + totalExpectedEngagements, + "Number of engagements" + ); + + let recordedAdImpressions = Glean.serp.adImpression.testGetValue() ?? []; + let idToAdImpressions = new Map(); + let totalExpectedAdImpressions = 0; + + // The list of ad impressions are contained in a flat list. Separate them + // into arrays organized by impressionId to make it easier to determine if + // the page load that matches the expected ads on the page. + for (let recordedAdImpression of recordedAdImpressions) { + let impressionId = recordedAdImpression.extra.impression_id; + Assert.ok(impressionId, "Ad impression has impression_id"); + + let arr = idToAdImpressions.get(impressionId) ?? []; + arr.push(recordedAdImpression.extra); + idToAdImpressions.set(impressionId, arr); + } + + for (let expectedEvent of expectedEvents) { + let impressionId = expectedEvent.impression.impression_id; + let expectedAdImpressions = expectedEvent.adImpressions ?? []; + if (expectedAdImpressions.length) { + let recorded = idToAdImpressions.get(impressionId) ?? {}; + Assert.deepEqual( + recorded, + expectedAdImpressions, + "Matching ad impression value." + ); + } + totalExpectedAdImpressions += expectedAdImpressions.length; + } + + Assert.equal( + recordedAdImpressions.length, + totalExpectedAdImpressions, + "Recorded and expected ad impression counts match." + ); + + // Assert abandonment events. + let recordedAbandonments = Glean.serp.abandonment.testGetValue() ?? []; + let idTorecordedAbandonments = new Map(); + let totalExpectedrecordedAbandonments = 0; + + for (let recordedAbandonment of recordedAbandonments) { + let impressionId = recordedAbandonment.extra.impression_id; + Assert.ok(impressionId, "Abandonment event has an impression_id."); + idTorecordedAbandonments.set(impressionId, recordedAbandonment.extra); + } + + for (let expectedEvent of expectedEvents) { + let impressionId = expectedEvent.impression.impression_id; + let expectedAbandonment = expectedEvent.abandonment; + if (expectedAbandonment) { + let recorded = idTorecordedAbandonments.get(impressionId); + Assert.deepEqual( + recorded, + expectedAbandonment, + "Matching abandonment value." + ); + } + totalExpectedrecordedAbandonments += expectedAbandonment ? 1 : 0; + } + + Assert.equal( + recordedAbandonments.length, + totalExpectedrecordedAbandonments, + "Recorded and expected abandonment counts match." + ); +} + +// TODO Bug 1868476: Replace when we're using Glean telemetry. +let categorizationSandbox; +let fakeTelemetryStorage = []; +add_setup(function () { + categorizationSandbox = sinon.createSandbox(); + categorizationSandbox + .stub(SERPCategorizationRecorder, "recordCategorizationTelemetry") + .callsFake(input => { + fakeTelemetryStorage.push(input); + }); + + registerCleanupFunction(() => { + categorizationSandbox.restore(); + fakeTelemetryStorage = []; + }); +}); + +function assertCategorizationValues(expectedResults) { + // TODO Bug 1868476: Replace with calls to Glean telemetry. + let actualResults = [...fakeTelemetryStorage]; + + Assert.equal( + expectedResults.length, + actualResults.length, + "Should have the correct number of categorization impressions." + ); + + if (!expectedResults.length) { + return; + } + + // We use keys in the result vs. Assert.deepEqual to make it easier to + // identify exact discrepancies in comparisons, because it can be tedious to + // parse a giant list of values. + let keys = new Set(); + for (let expected of expectedResults) { + for (let key in expected) { + keys.add(key); + } + } + for (let actual of actualResults) { + for (let key in actual) { + keys.add(key); + } + } + keys = Array.from(keys); + + for (let index = 0; index < expectedResults.length; ++index) { + info(`Checking categorization at index: ${index}`); + let expected = expectedResults[index]; + let actual = actualResults[index]; + for (let key of keys) { + // TODO Bug 1868476: This conversion to strings is to mimic Glean + // converting all values into strings. Once we receive real values from + // Glean, it can be removed. + if (actual[key] != null && typeof actual[key] !== "string") { + actual[key] = actual[key].toString(); + } + Assert.equal( + actual[key], + expected[key], + `Actual and expected values for ${key} should match.` + ); + } + } +} + +function waitForPageWithAdImpressions() { + return TestUtils.topicObserved("reported-page-with-ad-impressions"); +} + +function waitForPageWithCategorizedDomains() { + return TestUtils.topicObserved("reported-page-with-categorized-domains"); +} + +function waitForSingleCategorizedEvent() { + return TestUtils.topicObserved("recorded-single-categorization-event"); +} + +function waitForAllCategorizedEvents() { + return TestUtils.topicObserved("recorded-all-categorization-events"); +} + +function waitForDomainToCategoriesUpdate() { + return TestUtils.topicObserved("domain-to-categories-map-update-complete"); +} + +registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); +}); + +async function mockRecordWithAttachment({ id, version, filename }) { + // Get the bytes of the file for the hash and size for attachment metadata. + let data = await IOUtils.readUTF8(getTestFilePath(filename)); + let buffer = new TextEncoder().encode(data).buffer; + let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance( + Ci.nsIArrayBufferInputStream + ); + stream.setData(buffer, 0, buffer.byteLength); + + // Generate a hash. + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + hasher.updateFromStream(stream, -1); + let hash = hasher.finish(false); + hash = Array.from(hash, (_, i) => + ("0" + hash.charCodeAt(i).toString(16)).slice(-2) + ).join(""); + + let record = { + id, + version, + attachment: { + hash, + location: `main-workspace/search-categorization/${filename}`, + filename, + size: buffer.byteLength, + mimetype: "application/json", + }, + }; + + let attachment = { + record, + blob: new Blob([buffer]), + }; + + return { record, attachment }; +} + +async function resetCategorizationCollection(record) { + const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); + await client.attachments.cacheImpl.delete(record.id); + await client.db.clear(); + await client.db.importChanges({}, Date.now()); +} + +async function insertRecordIntoCollection() { + const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); + const db = client.db; + + await db.clear(); + let { record, attachment } = await mockRecordWithAttachment({ + id: "example_id", + version: 1, + filename: "domain_category_mappings.json", + }); + await db.create(record); + await client.attachments.cacheImpl.set(record.id, attachment); + await db.importChanges({}, Date.now()); + + return { record, attachment }; +} + +async function insertRecordIntoCollectionAndSync() { + let { record } = await insertRecordIntoCollection(); + + registerCleanupFunction(async () => { + await resetCategorizationCollection(record); + }); + + await syncCollection(record); +} + +async function syncCollection(record) { + let arrayWithRecord = record ? [record] : []; + await RemoteSettings(TELEMETRY_CATEGORIZATION_KEY).emit("sync", { + data: { + current: arrayWithRecord, + created: arrayWithRecord, + updated: [], + deleted: [], + }, + }); +} + +async function initSinglePageAppTest() { + /* import-globals-from head-spa.js */ + Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/search/test/browser/telemetry/head-spa.js", + this + ); + + const BASE_PROVIDER = { + telemetryId: "example1", + searchPageRegexp: + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetrySinglePageApp/, + queryParamNames: ["s"], + codeParamName: "abc", + taggedCodes: ["ff"], + adServerAttributes: ["mozAttr"], + extraAdServersRegexps: [ + /^https:\/\/example\.com\/ad/, + /^https:\/\/example.org\/browser\/browser\/components\/search\/test\/browser\/telemetry\/redirect_ad/, + ], + components: [ + { + included: { + parent: { + selector: "#searchbox-container", + }, + related: { + selector: "#searchbox-suggestions", + }, + children: [ + { + selector: "#searchbox", + }, + ], + }, + topDown: true, + type: SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + }, + { + type: SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + default: true, + }, + ], + isSPA: true, + defaultPageQueryParam: { + key: "page", + value: "web", + }, + }; + + const SPA_PROVIDER_INFO = [ + BASE_PROVIDER, + { + ...BASE_PROVIDER, + telemetryId: "example2", + // Use example.com instead of example.org so that we have two providers + // with different TLD's and won't share the same web process. + searchPageRegexp: + /^https:\/\/example.com\/browser\/browser\/components\/search\/test\/browser\/telemetry\/searchTelemetrySinglePageApp/, + }, + ]; + + SearchSERPTelemetry.overrideSearchTelemetryForTests(SPA_PROVIDER_INFO); + await waitForIdle(); + + // Shorten delay to avoid potential TV timeouts. + Services.ppmm.sharedData.set(SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT, 100); + + registerCleanupFunction(function () { + Services.ppmm.sharedData.set( + SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT, + SPA_ADLINK_CHECK_TIMEOUT_MS + ); + }); +} diff --git a/browser/components/search/test/browser/telemetry/redirect_ad.sjs b/browser/components/search/test/browser/telemetry/redirect_ad.sjs new file mode 100644 index 0000000000..36be567d3f --- /dev/null +++ b/browser/components/search/test/browser/telemetry/redirect_ad.sjs @@ -0,0 +1,10 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_final.sjs", false); + response.setHeader("Cache-Control", "no-cache, must-revalidate", false); +} diff --git a/browser/components/search/test/browser/telemetry/redirect_final.sjs b/browser/components/search/test/browser/telemetry/redirect_final.sjs new file mode 100644 index 0000000000..14debde6ba --- /dev/null +++ b/browser/components/search/test/browser/telemetry/redirect_final.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "https://example.com/hello_world", false); +} diff --git a/browser/components/search/test/browser/telemetry/redirect_once.sjs b/browser/components/search/test/browser/telemetry/redirect_once.sjs new file mode 100644 index 0000000000..d15f3afe6d --- /dev/null +++ b/browser/components/search/test/browser/telemetry/redirect_once.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_final.sjs", false); +} diff --git a/browser/components/search/test/browser/telemetry/redirect_thrice.sjs b/browser/components/search/test/browser/telemetry/redirect_thrice.sjs new file mode 100644 index 0000000000..b7c7069162 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/redirect_thrice.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_twice.sjs", false); +} diff --git a/browser/components/search/test/browser/telemetry/redirect_twice.sjs b/browser/components/search/test/browser/telemetry/redirect_twice.sjs new file mode 100644 index 0000000000..099d20022e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/redirect_twice.sjs @@ -0,0 +1,9 @@ +/** + * Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +function handleRequest(request, response) { + response.setStatusLine("1.1", 302, "Found"); + response.setHeader("Location", "redirect_once.sjs", false); +} diff --git a/browser/components/search/test/browser/telemetry/searchTelemetry.html b/browser/components/search/test/browser/telemetry/searchTelemetry.html new file mode 100644 index 0000000000..bd395d4a7c --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetry.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a href="https://example.com/otherpage">Non ad link</a> + <a href="https://example1.com/ad">Matching path prefix, different server</a> + <a href="https://mochi.test:8888/otherpage">Non ad link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd.html new file mode 100644 index 0000000000..23d51d2fb5 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a id="ad1" href="https://example.com/ad">Ad link</a> + <a id="ad2" href="https://example.com/ad2">Second Ad link</a> + <!-- The iframe is used to include a sub-document load in the test, which + should not affect the recorded telemetry. --> + <iframe src="searchTelemetry.html"></iframe> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel.html new file mode 100644 index 0000000000..71049be20c --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel.html @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + Carousels can have multiple hidden links. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <button type="button">Next</button> + </div> + <!-- + Carousels can be used for non-ads. + --> + <h5 test-label="true">non_ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Giraffes</h3> + </a> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Rhinos</h3> + </a> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html new file mode 100644 index 0000000000..737e1e654b --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html @@ -0,0 +1,83 @@ +<!-- + This is for testing a carousel below the fold. +--> +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top" style="padding-top: 1000px;"> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel-container"> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html new file mode 100644 index 0000000000..f7b7f948d9 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html @@ -0,0 +1,182 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + Carousels can have multiple hidden links. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel" narrow="true" id="second-ad"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + <!-- + Carousels can be used for non-ads. + --> + <h5 test-label="true">non_ad_carousel</h5> + <div class="moz-carousel" narrow="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Giraffes</h3> + </a> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/some-normal-path"></a> + <a href="https://example.com/normal-path"> + <h3>Rhinos</h3> + </a> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html new file mode 100644 index 0000000000..b5a44b325e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html @@ -0,0 +1,85 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + If a user scrolls a carousel before the impression is snapped, + we shouldn't count elements that aren't fully shown in the carousel + as visible. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel-container"> + <div class="moz-carousel" narrow="true"> + <div style="margin-left: -80px;" class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html new file mode 100644 index 0000000000..cccd714326 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html @@ -0,0 +1,87 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" + href="./serp.css" /> +</head> +<body> + <section id="top"> + <h5 test-label="true">ad_carousel with display: none;</h5> + <div class="moz-carousel" narrow="true" style="display: none;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + + <h5 test-label="true">ad_carousel with no width;</h5> + <div class="moz-carousel" narrow="true" style="width: 0;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + </div> + </div> + </div> + </div> + + <h5 test-label="true">ad_carousel with no height;</h5> + <div class="moz-carousel" narrow="true" style="height: 0;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + </div> + </div> + </div> + </div> + + <h5 test-label="true">ad_carousel that is far above the page</h5> + <div class="moz-carousel" narrow="true" style="position: absolute; top: -9999px;"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html new file mode 100644 index 0000000000..759bd9f0d9 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <!-- + Carousels can sometimes have an outer container that doesn't always show up. + --> + <h5 test-label="true">ad_carousel</h5> + <div class="moz-carousel-container"> + <div class="moz-carousel" extra="true"> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + <div class="moz-carousel-card"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/some-normal-path"> + <div class="moz-carousel-image">Image</div> + </a> + <div class="moz-carousel-card-inner"> + <div class="moz-carousel-card-inner-content"> + <a class="hidden" href="https://example.com/ad"></a> + <a href="https://example.com/normal-path"> + <h3>Name of Product</h3> + </a> + <h3>$199.99</h3> + <h3>Example.com</h3> + </div> + </div> + </div> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html new file mode 100644 index 0000000000..7985fb2c51 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="searchresults"> + <div class="lhs"> + <div class="moz_ad"> + <h5 test-label>ad_sitelink</h5> + <!-- + Note that the query parameter keys are in reverse alphabetical order + that will be reversed in the tests. + --> + <a id="ad_sitelink" href="https://example.com/ad?foo=bar0&baz=bar0"> + <h2>Example Result</h2> + </a> + <div class="multi-col"> + <div> + <a href="https://example.com/ad?foo=bar1&baz=bar1"> + <h2>New Releases</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + </div> + </div> + <div class="moz_ad"> + <h5 test-label>ad_link</h5> + <a id="ad_link" href="https://example.com/ad?foo=bar2&baz=bar2"> + <h2>Example Result</h2> + </a> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html new file mode 100644 index 0000000000..66f056fb25 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html @@ -0,0 +1,112 @@ +<!-- + Text ads reuse the data-ad element in multiple components to make it + difficult to determine which component it belongs to, similar to Bing. +--> +<!DOCTYPE html> +<html lang="en"> +<head> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="searchresults"> + <div class="lhs"> + <div class="moz_ad"> + <h5 test-label>ad_sitelink</h5> + <a href="https://example.com/ad/1"> + <h2>Example Result</h2> + </a> + <span><a href="https://example.com/ad/2">Ad link that says there are 10 Locations nearby</a></span> + <div class="multi-col"> + <div> + <a href="https://example.com/ad/3"> + <h2>New Releases</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + <div> + <a id="deep_ad_sitelink" href="https://example.com/ad/4"> + <h2>Men's</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + <div> + <a href="https://example.com/ad/5"> + <h2>Women's</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + <div> + <!-- Ensure ads encoded in data-attributes are also recorded properly --> + <a data-moz-attr="https://example.com/ad/6" href="https://example.com/normal-link"> + <h2>Sale</h2> + </a> + <span>Cras ac velit sed tellus</span> + </div> + </div> + </div> + <div class="moz_ad"> + <h5 test-label>ad_link</h5> + <a id="ad_link_redirect" href="https://example.org/browser/browser/components/search/test/browser/telemetry/redirect_ad.sjs"> + <h2>Example Shop</h2> + </a> + <div class="factrow"> + <a href="https://example.com/ad/8">Home Page</a> + <a href="https://example.com/ad/9">Products</a> + <a href="https://example.com/ad/10">Sales</a> + </div> + </div> + <div class="moz_ad"> + <h5 test-label>ad_link</h5> + <a href="https://example.com/ad/11"> + <h2>Example Shop</h2> + </a> + </div> + <div> + <h5 test-label>non_ads_link</h5> + <a id="non_ads_link" href="https://example.com/browser/browser/components/search/test/browser/telemetry/cacheable.html"> + Example of a cached non ad + </a><br /> + <a id="non_ads_link_redirected" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html"> + Example of a redirected non ad link + </a><br /> + <a id="non_ads_link_redirected_no_top_level" href="#"> + Example of a redirected non ad link that isn't initially top level loaded + </a><br /> + <a id="non_ads_link_multiple_redirects" href="https://example.com/browser/browser/components/search/test/browser/telemetry/redirect_thrice.sjs"> + Example of a redirected non ad link that's redirected multiple times + </a><br /> + <a id="non_ads_link_with_special_characters_in_path" href="https://example.com/path'?hello_world&foo=bar's"> + Example of a non ad with special characters in path + </a> + </div> + </div> + <div class="rhs"> + <h5 test-label>ad_sidebar</h5> + <div class="moz_ad"> + <a href="https://example.com/ad/15"> + <div class="mock-image">Mock ad image</div> + </a> + <a href="https://example.com/ad/16"> + <h3>Buy Example Now</h3> + </a> + <p>Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</p> + <a href="https://example.com/ad/17">Buy Now</a> + </div> + </div> + </section> + <iframe style="display: none;"></iframe> + <script> + window.addEventListener("message", (event) => { + if (event.origin == "https://example.org") { + window.location.href = event.data; + } + }); + document.getElementById("non_ads_link_redirected_no_top_level") + .addEventListener("click", (event) => { + event.preventDefault(); + let iframe = document.querySelector("iframe"); + iframe.src = "https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html"; + }); + </script> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html new file mode 100644 index 0000000000..475ada3a3c --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html @@ -0,0 +1,46 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section id="top"> + <div style="display: flex; gap: 20px;"> + <div> + <h5 test-label="true">ad_link</h5> + <!-- The parent size exceeds the window height but the first ad link is above the fold. --> + <div class="moz_ad" style="padding-bottom: 2000px;"> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + <div> + <h5 test-label="true" >ad_link</h5> + <a href="https://example.com/ad">Ad Link</a> + </div> + <!-- The ad links are below the fold but the test will scroll to it before the impression is recorded. --> + <div> + <h5 test-label="true">ad_link</h5> + <div id="second-ad" class="moz_ad" style="padding-top: 2000px;"> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + <div> + <h5 test-label="true" style="margin-bottom: 2000px;">ad_link</h5> + <a href="https://example.com/ad">Ad Link</a> + </div> + <!-- The ad links are below the fold and shouldn't be viewed in the test. --> + <div> + <h5 test-label="true">ad_link</h5> + <div class="moz_ad" style="padding-top: 4000px;"> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + <div> + <h5 test-label="true" style="margin-bottom: 4000px;">ad_link</h5> + <a href="https://example.com/ad">Ad Link</a> + </div> + </div> + </section> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html new file mode 100644 index 0000000000..7bc1b2745e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html @@ -0,0 +1,10 @@ +<!-- This HTML file encodes the ad link in the data attribute --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a data-xyz="https://example.com/ad123" href="https://example.com/otherpage">Ad link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html new file mode 100644 index 0000000000..319485cfae --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html @@ -0,0 +1,10 @@ +<!-- This HTML file encodes the ad link in the href attribute and has irrelevant data in data attribute --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a data-xyz="https://example.com/otherpage" href="https://example.com/ad123">Ad link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html new file mode 100644 index 0000000000..a119cf71be --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html @@ -0,0 +1,10 @@ +<!-- This HTML file has non-ad data in both the href and data attribute --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a data-xyz="https://example.com/otherpage" href="https://example.com/otherpage">Non-Ad Link</a> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html new file mode 100644 index 0000000000..d987356d7e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Page will do a redirect</title> + <meta content="0;url=https://example.com/hello_world" http-equiv="refresh"> +</head> +<body> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html new file mode 100644 index 0000000000..1c5c31cb38 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Page will do a redirect without doing it in a top load</title> + <!-- <meta content="0;url=https://example.com/hello_world" http-equiv="refresh"> --> + <script> + let parentWindow = window.parent; + let url = "https://example.com/hello_world"; + parentWindow.postMessage(url, "*"); + </script> + </head> + <body> + </body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ new file mode 100644 index 0000000000..419697b050 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^ @@ -0,0 +1,4 @@ +Cache-Control: no-cache, must-revalidate +Pragma: no-cache +Expires: Fri, 01 Jan 1990 00:00:00 GMT +Content-Type: text/html; charset=ISO-8859-1 diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html new file mode 100644 index 0000000000..7ba3f84f6b --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section> + <form role="search"> + <input type="text" value="test" /> + <div> + <ul> + <li id="suggest">test</li> + </div> + </form> + </section> + <section id="searchresults"> + <div class="lhs"> + <div> + <h5 test-label>non_ads_link</h5> + <a id="non_ads_link" href="https://example.com/hello_world"> + <h2>Example of a non ad</h2> + </a> + </div> + </div> + </section> +</body> +<script type="text/javascript"> + document.querySelector("form").addEventListener("submit", event => { + event.preventDefault(); + window.location.href = "https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html?s=test+suggest&abc=ff"; + }) + document.getElementById("suggest").addEventListener("click", event => { + event.preventDefault(); + window.location.href = "https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html?s=test+suggest&abc=ff"; + }) +</script> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ new file mode 100644 index 0000000000..62847d0585 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^ @@ -0,0 +1 @@ +Cache-Control: private, max-age=0 diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html new file mode 100644 index 0000000000..9c4d371691 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html @@ -0,0 +1,39 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <link rel="stylesheet" type="text/css" href="./serp.css" /> +</head> +<body> + <section> + <form role="search"> + <input type="text" value="test" /> + </form> + </section> + <nav> + <a id="images" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test&page=images">Images</a> + <a id="shopping" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test&page=shopping">Shopping</a> + <a id="extra" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html?s=test">Extra Page</a> + </nav> + <section class="refined-search-buttons"> + <a id="refined-search-button" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test's">Test's</a> + <a id="refined-search-button-with-partner-code" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test2&abc=ff">Test 2</a> + </section> + <section id="searchresults"> + <div class="lhs"> + <div> + <h2>Related Searches</h2> + <a id="related-new-tab" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three" target="_blank">test one two three</a> + <a id="related-redirect" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html" target="_blank">test one two three</a> + <a id="related-in-page" href="https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three">test one two three</a> + </div> + </div> + </section> +</body> +<script type="text/javascript"> + document.querySelector("form").addEventListener("submit", event => { + event.preventDefault(); + window.location.href = "https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test&abc=ff"; + }); +</script> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html new file mode 100644 index 0000000000..c8a3245446 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Page will do a redirect</title> + <meta content="0;url=https://example.org/browser/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html?s=test+one+two+three" http-equiv="refresh"> +</head> +<body> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ new file mode 100644 index 0000000000..94cde2a288 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^ @@ -0,0 +1 @@ +Cache-Control: no-cache, must-revalidate diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html b/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html new file mode 100644 index 0000000000..faa6c057a4 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <nav> + <a href="https://example.org/search?q=something&page=images&foo=bar">Images</a> + <a id="shopping" href="https://example.org/search?q=something&page=shopping&foo=bar">Shopping</a> + </nav> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html new file mode 100644 index 0000000000..b9569ba2d6 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <div id="results"> + <!-- Don't include domains matching the provider. --> + <div class="organic"> + <a href="https://www.example.com"></a> + <a href="https://example.com"></a> + </div> + <div class="organic"> + <a href="https://www.foobar.org"></a> + </div> + <div data-ad-domain="abc.org"> + <a href="https://www.example.com/"></a> + </div> + <div> + <a class="ad" href="https://www.example.com/?ad_domain=def.org"></a> + </div> + <!-- Don't throw on anchors with non-standard or non-existent hrefs --> + <div> + <a href="javascript:console.log('hello world')">A javascript: URL link</a> + </div> + <div> + <a>An anchor that's missing an href attribute</a> + </div> + <div> + <a href="#">An anchor with a dummy href attribute value</a> + </div> + </div> + <aside> + <div class="organic"> + <a href="https://foobaz.com"></a> + </div> + </aside> + <div class="organic"> + <!-- Should not find this because it's not part of the results --> + <a href="https://outside-results.ca"></a> + </div> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html new file mode 100644 index 0000000000..63a44b8e77 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <div id="results"> + <div class="organic"> + <a href="https://www.test1.com">Organic Link 1</a> + <a href="https://www.test2.com">Organic Link 2</a> + <a href="https://www.test3.com">Organic Link 3</a> + <a href="https://www.test4.com">Organic Link 4</a> + <a href="https://www.test5.com">Organic Link 5</a> + <a href="https://www.test6.com">Organic Link 6</a> + <a href="https://www.test7.com">Organic Link 7</a> + <a href="https://www.test8.com">Organic Link 8</a> + <a href="https://www.test9.com">Organic Link 9</a> + <a href="https://www.test10.com">Organic Link 10</a> + <a href="https://www.test11.com">Organic Link 11</a> + <a href="https://www.test12.com">Organic Link 12</a> + </div> + + <div class="ad"> + <div data-ad-domain="foo.com"> + <a href="https://www.test13.com/">Non-organic Link 1</a> + </div> + <div data-ad-domain="bar.com"> + <a href="https://www.test14.com/">Non-organic Link 2</a> + </div> + <div data-ad-domain="baz.com"> + <a href="https://www.test15.com/">Non-organic Link 3</a> + </div> + <div data-ad-domain="qux.com"> + <a href="https://www.test16.com/">Non-organic Link 4</a> + </div> + <div data-ad-domain="abc.com"> + <a href="https://www.test17.com/">Non-organic Link 5</a> + </div> + <div data-ad-domain="def.com"> + <a href="https://www.test18.com/">Non-organic Link 6</a> + </div> + <div> + <a class="ad" href="https://www.test19.com/?ad_domain=ghi.org">Non-organic Link 7</a> + </div> + <div> + <a class="ad" href="https://www.test20.com/?ad_domain=jkl.org">Non-organic Link 8</a> + </div> + <div> + <a class="ad" href="https://www.test21.com/?ad_domain=mno.org">Non-organic Link 9</a> + </div> + <div> + <a class="ad" href="https://www.test22.com/?ad_domain=pqr.org">Non-organic Link 10</a> + </div> + <div> + <a class="ad" href="https://www.test23.com/?ad_domain=stu.org">Non-organic Link 11</a> + </div> + <div> + <a class="ad" href="https://www.test24.com/?ad_domain=vwx.org">Non-organic Link 12</a> + </div> + </div> + </div> +</body> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html new file mode 100644 index 0000000000..22f763191a --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <div id="results"> + <!-- Don't include domains matching the provider. --> + <div class="organic"> + <a href="https://www.example.com">Link</a> + <a href="https://example.com">Link</a> + </div> + <div class="organic"> + <a href="https://www.foobar.org">Link</a> + </div> + <div data-ad-domain="abc.org"> + <a href="https://example.com/ad">Sponsored Link</a> + </div> + <div> + <a class="ad" href="https://example.com/ad?ad_domain=def.org">Sponsored Link</a> + </div> + <!-- Don't throw on anchors with non-standard or non-existent hrefs --> + <div> + <a href="javascript:console.log('hello world')">A javascript: URL link</a> + </div> + <div> + <a>An anchor that's missing an href attribute</a> + </div> + <div> + <a href="#">An anchor with a dummy href attribute value</a> + </div> + </div> + <aside> + <div class="organic"> + <a href="https://foobaz.com"></a> + </div> + </aside> + <div class="organic"> + <!-- Should not find this because it's not part of the results --> + <a href="https://outside-results.ca"></a> + </div> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html new file mode 100644 index 0000000000..b49e5610ae --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html @@ -0,0 +1,84 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Document</title> +</head> +<body> + <div id="results"> + <div id="test1"> + <div data-layout="organic"> + <a href="https://foobar.com" data-testid="result-title-a">Extract domain from href (absolute URL).</a> + </div> + </div> + + <div id="test2"> + <div data-layout="organic"> + <a href="https://foo.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link1.</a> + <a href="https://bar.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link2.</a> + <a href="https://baz.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link3.</a> + <a href="https://qux.com" data-testid="result-title-a">Extract domain from href (absolute URL) - link4.</a> + </div> + </div> + + <div id="test3"> + <div data-layout="organic"> + <a href="/dummy-page" data-testid="result-title-a">Extract domain from href (relative URL).</a> + </div> + </div> + + <div id="test4"> + <a href="#" data-dtld="www.abc.com">Extract domain from data attribute.</a> + </div> + + <div id="test5"> + <a href="#" data-dtld="www.foo.com">Extract domain from data attribute - link1.</a> + <a href="#" data-dtld="www.bar.com">Extract domain from data attribute - link2.</a> + <a href="#" data-dtld="www.baz.com">Extract domain from data attribute - link3.</a> + <a href="#" data-dtld="www.qux.com">Extract domain from data attribute - link4.</a> + </div> + + <div id="test6"> + <a href="example.com/testing?ad_domain=def.com" class="js-carousel-item-title">Extract domain from an href's query param value.</a> + </div> + + <div id="test7"> + <a href="https://example.com/test?ad_domain=https://def.com/path/to/nowhere">Extract domain from an href's query param value containing an absolute href.</a> + </div> + + <div id="test8"> + <a href="https://example.com/test?ad_domain=def.com/path/to/nowhere">Extract domain from an href's query param value containing a relative href.</a> + </div> + + <div id="test9"> + <a href="https://example.com/test?dummy_key=foo.com">Param value is missing from the href.</a> + </div> + + <div id="test10"> + <!-- Extraction preserves order of domains within the page. --> + <div data-layout="organic"> + <a href="https://foobar.com" data-testid="result-title-a">Extract domain from href (absolute URL).</a> + <a href="#" data-dtld="www.abc.com">Extract domain from data attribute.</a> + <a href="example.com/testing?ad_domain=def.com" class="js-carousel-item-title">Extract domain from an href's query param value.</a> + </div> + </div> + + <div id="test11"> + <a href="nomatches.com">Link that doesn't match a selector.</a> + </div> + + <div id="test12"> + <a href="#" data-dtld="">Data attribute is present, but value is missing.</a> + </div> + + <div id="test13"> + <a href="example.com/testing?ad_domain=" class="js-carousel-item-title">Query param is present, but value is missing.</a> + </div> + + <div id="test14"> + <a href="git://testing.com/testrepo">Non-standard URL scheme.</a> + </div> + </div> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html b/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html new file mode 100644 index 0000000000..7598da694e --- /dev/null +++ b/browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html @@ -0,0 +1,243 @@ +<!DOCTYPE html> +<!-- + This SERP loads content dynamically. When a search term is provided in the + query parameter, it'll populate results using it. + + Clicking images will load a fake results page with some ad links to ensure + we aren't tracking them again. + + Searching "no-ads" will load a results page with no ads. This is so that if + there are multiple tabs open with a SERP and we tell an actor to look for an + ad, there shouldn't be any results. +--> +<html> +<head> + <title>Fake SERP</title> + <meta charset="utf-8"> +</head> +<body> + <nav style="display: flex; gap: 10px;"> + <a id="all" data-menu="all">All</a> + <a id="images" data-menu="images">Images</a> + </nav> + <div id="searchbox-container"> + <input id="searchbox" type="text" placeholder="search" /> + <div id="searchbox-suggestions"></div> + </div> + <div style="margin: 10px 0;" id="results"></div> + <div id="related-searches"></div> + <script> + const allTab = document.querySelector("[data-menu='all']"); + const imagesTab = document.querySelector("[data-menu='images']"); + const results = document.getElementById("results"); + const related = document.getElementById("related-searches"); + const searchBox = document.getElementById("searchbox"); + const suggestion = document.getElementById("searchbox-suggestions"); + let searchKey = "s"; + + function getSearchTerm(){ + let searchTerm = new URL(window.location.href).searchParams.get(searchKey); + if (!searchTerm) { + return ""; + } + return { originalSearchTerm: searchTerm, searchTerm: searchTerm.replaceAll("+", " ") }; + } + + function replaceWithBasicResults() { + let { originalSearchTerm, searchTerm } = getSearchTerm(); + let hasAds = !searchTerm.startsWith("no ads"); + if (!searchTerm) { + return; + } + let result = ` + <div> + <a id="organic" href="https://example.com/nonad+${originalSearchTerm}"> + Non Ad Result - ${searchTerm} + </a> + </div> + `; + if (hasAds) { + result += ` + <div> + <a id="ad" href="https://example.com/ad+${originalSearchTerm}"> + Ad Result - ${searchTerm} + </a> + </div> + <div> + <a id="ad-redirect" href="https://example.org/browser/browser/components/search/test/browser/telemetry/redirect_ad.sjs"> + Ad Result Redirect - ${searchTerm} + </a> + </div> + `; + } + results.innerHTML = result; + } + + function replaceWithOtherResults() { + let { searchTerm } = getSearchTerm(); + if (!searchTerm) { + return; + } + let result = ` + <div style="width: 200px; height: 100px; background-color: #333;"> + <a style="color: #FFF;" + href="https://example.com/otherpage">Non Ad Image - ${searchTerm} + </a> + </div> + <div style="width: 200px; height: 100px; background-color: #333;"> + <a style="color: #FFF;" + href="https://example.com/ad">Ad Image - ${searchTerm} + </a> + </div> + `; + results.innerHTML = result; + } + + function updateSearchbox() { + let { searchTerm } = getSearchTerm(); + searchBox.value = searchTerm; + } + + function updateSuggestions() { + let { searchTerm } = getSearchTerm(); + let suggestions = ` + <div id="first-suggestion" data-search="${searchTerm} suggestion">${searchTerm} suggestion</div> + ` + suggestion.innerHTML = suggestions; + } + + function updateNav() { + let baseUrl = new URL(window.location.href); + + baseUrl.searchParams.set("page", "web"); + allTab.href = baseUrl.href; + + baseUrl.searchParams.set("page", "images"); + imagesTab.href = baseUrl.href; + } + + function updatePageState({ page = "", query = "" }) { + let url = new URL(window.location.href); + if (page) { + url.searchParams.set("page", page); + } + if (query) { + url.searchParams.set(searchKey, query); + } + history.pushState({}, "", url); + } + + function updateRelatedSearches() { + let url = new URL(window.location.href); + let searchTerm = url.searchParams.get(searchKey); + let page = url.searchParams.get("page"); + + let innerResults = ""; + if (searchTerm && page == "web") { + innerResults = ` + <div> + <a id="related-search" data-search="how to ${searchTerm}" href="#"> + how to ${searchTerm} + </a> + </div> + <div> + <a id="related-search-without-ads" data-search="no ads ${searchTerm}" href="#"> + no ads related result for ${searchTerm} + </a> + </div> + `; + } + document.getElementById("related-searches").innerHTML = innerResults; + } + + allTab.addEventListener("click", (event) => { + event.preventDefault(); + updatePageState({ page: "web" }); + replaceWithBasicResults(); + updateRelatedSearches(); + updateSearchbox(); + updateSuggestions(); + }); + + imagesTab.addEventListener("click", (event) => { + event.preventDefault(); + updatePageState({ page: "images" }); + replaceWithOtherResults(); + updateRelatedSearches(); + updateSearchbox(); + updateSuggestions(); + }); + + related.addEventListener("click", (event) => { + event.preventDefault(); + let search = event.target.dataset.search; + if (search) { + updatePageState({ page: "web", query: search }); + replaceWithBasicResults(); + updateRelatedSearches(); + updateNav(); + updateSearchbox(); + updateSuggestions(); + } + }); + + suggestion.addEventListener("click", (event) => { + event.preventDefault(); + let search = event.target.dataset.search; + let baseUrl = new URL(window.location.href); + let page = baseUrl.searchParams.get("page"); + updatePageState({ page, query: search }); + switch (page) { + case "web": + replaceWithBasicResults(); + updateRelatedSearches(); + updateNav(); + updateSearchbox(); + updateSuggestions(); + break; + case "images": + replaceWithOtherResults(); + updateRelatedSearches(); + updateSearchbox(); + updateSuggestions(); + break; + } + }) + + window.addEventListener("DOMContentLoaded", (event) => { + let url = new URL(window.location.href); + searchKey = url.searchParams.has("r") ? "r": "s"; + + // When the page is loaded, we add a query parameter denoting the type + // of SERP this belongs to, mimicking how some SERPs operate. + url.searchParams.set("page", "web"); + history.replaceState({}, "", url); + replaceWithBasicResults(); + updateNav(); + updateRelatedSearches(); + updateSearchbox(); + updateSuggestions(); + }); + + window.addEventListener("popstate", (event) => { + let baseUrl = new URL(window.location.href); + let page = baseUrl.searchParams.get("page"); + switch (page) { + case "web": + replaceWithBasicResults(); + updateNav(); + updateRelatedSearches(); + updateSearchbox(); + updateSuggestions(); + break; + case "images": + replaceWithOtherResults(); + updateRelatedSearches(); + updateSearchbox(); + break; + } + }); + + </script> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/serp.css b/browser/components/search/test/browser/telemetry/serp.css new file mode 100644 index 0000000000..5b3865da44 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/serp.css @@ -0,0 +1,164 @@ +:root { + --margin-left: 80px; + --subtle: whitesmoke; + --carousel-card-width: 180px; +} + +body { + margin: 0; + padding: 0 0 80px 0; +} + +a:link { + text-decoration: none; +} + +a:visited { + color: blue; +} + +h5[test-label] { + margin-top: 30px; + margin-bottom: 4px; +} + +nav { + border-bottom: 1px solid #ececec; + padding-bottom: 20px; + margin-bottom: 20px; +} + +#searchform { + padding-top: 20px; + margin-bottom: 20px; +} + +nav>div, +#searchform, +.moz-carousel, +.factrow { + display: flex; + align-items: center; +} + +nav>div, +#searchform { + gap: 40px; +} + +nav>div, +#searchform, +#searchresults, +#top { + margin-left: var(--margin-left); +} + +#searchbox { + font-size: 14px; + padding: 10px 20px; + width: 300px; + border-radius: 20px; + border: 2px solid var(--subtle); + height: 20px; +} + +.card-container { + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; +} + +.card-container>.card { + height: 160px; + border-radius: 3px; + border: 1px solid var(--subtle); + display: inline-block; + box-sizing: border-box; + padding: 10px; +} + +.card-container>.card:not(:last-child) { + margin-right: 10px; +} + +.card-container>.card>a { + display: block; + margin-bottom: 2px; +} + +#searchresults { + width: 900px; + display: grid; + grid-template-columns: 600px 300px; +} + +.moz-carousel, +.factrow { + gap: 10px; +} + +.moz-carousel { + overflow: hidden; +} + +.moz-carousel[narrow], +.moz-carousel-container { + width: calc(var(--carousel-card-width) * 3 + (3 * 10px)); + overflow-x: auto; +} + +.moz-carousel[extra] { + width: calc(var(--carousel-card-width) * 4 + (3 * 10px)); +} + +.moz-carousel>.moz-inner { + border: 1px solid var(--subtle); + border-radius: 10px; + padding: 10px; +} + +.moz-carousel>.moz-carousel-card { + flex: 1 0 var(--carousel-card-width); + border: 1px solid var(--subtle); + font-size: 14px; +} + +.moz-carousel-card .moz-carousel-image { + width: 100%; + height: 120px; + background-color: var(--subtle); + display: flex; + align-items: center; + justify-content: center; +} + +.moz-carousel-card-inner-content { + padding: 10px 20px 20px 20px; +} + +.multi-col { + display: grid; + padding: 10px 20px 20px 20px; + grid-template-columns: 1fr 1fr; + gap: 10px; +} + +.mock-image { + height: 100px; + background-color: var(--subtle); + display: flex; + align-items: center; + justify-content: center; +} + +/* Some SERPs hide anchors using CSS */ +.hidden { + display: none; +} + +/* Typography */ +h2 { + line-height: 100%; + margin-bottom: 10px; + margin-top: 10px; +} diff --git a/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html new file mode 100644 index 0000000000..8408066897 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <a id="ad1" href="https://example.com/ad">Ad link</a> + <a id="ad2" href="https://example.com/ad2">Second Ad link</a> + <!-- The iframe is used to include a sub-document load in the test, which + should not affect the recorded telemetry. --> + <iframe src="searchTelemetry.html"></iframe> + <img src="https://example.org/browser/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs"> +</body> +</html> diff --git a/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs new file mode 100644 index 0000000000..7a6382d1cb --- /dev/null +++ b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(request, response) { + const DELAY_MS = 2000; + response.processAsync(); + + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "image/png", false); + response.write("Start loading image"); + + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.init( + () => { + response.write("Finish loading image"); + response.finish(); + }, + DELAY_MS, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html new file mode 100644 index 0000000000..517dd30206 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body id='body'> +</body> + <img src="https://example.org/browser/browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs"> + <script> + setTimeout(() => { + let body = document.getElementById('body'); + let ad1 = document.createElement('a'); + ad1.setAttribute('id', 'ad1'); + ad1.setAttribute('href', 'https://example.com/ad'); + ad1.innerHTML = 'Ad link' + + let ad2 = document.createElement('a'); + ad2.setAttribute('id', 'ad2'); + ad2.setAttribute('href', 'https://example.com/ad2'); + ad2.innerHTML = 'Second Ad link' + + let frame = document.createElement('iframe'); + frame.setAttribute('src', 'searchTelemetry.html'); + + body.appendChild(ad1); + body.appendChild(ad2); + body.appendChild(frame); + }, 2000); + </script> +</html> diff --git a/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs new file mode 100644 index 0000000000..1978b4f665 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs @@ -0,0 +1,9 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml new file mode 100644 index 0000000000..4a3f6cdf33 --- /dev/null +++ b/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml @@ -0,0 +1,6 @@ +<?xml version="1.0" encoding="UTF-8"?> +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_UsageTelemetry usageTelemetrySearchSuggestions.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://example.com" rel="searchform"/> +</SearchPlugin> 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_chromeicon.xml b/browser/components/search/test/browser/testEngine_chromeicon.xml new file mode 100644 index 0000000000..3ce80bcaea --- /dev/null +++ b/browser/components/search/test/browser/testEngine_chromeicon.xml @@ -0,0 +1,12 @@ +<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" + xmlns:moz="http://www.mozilla.org/2006/browser/search/"> + <ShortName>FooChromeIcon</ShortName> + <Description>Foo Chrome Icon Search</Description> + <InputEncoding>utf-8</InputEncoding> + <Image width="16" height="16">chrome://browser/skin/info.svg</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>foochromeiconalias</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..358d2a6077 --- /dev/null +++ b/browser/components/search/test/browser/trendingSuggestionEngine.sjs @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +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, query, query0, query1, query2 etc. + let query = params.query || ""; + let suffixes = [...Array(15).keys()].map(s => query + s); + // If we have a query, echo it back (to help test deduplication) + if (query) { + suffixes.unshift(query); + } + let data = [query, suffixes]; + + if (params?.richsuggestions) { + data.push([]); + data.push({ + "google:suggestdetail": data[1].map(() => ({ + 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.toml b/browser/components/search/test/marionette/manifest.toml new file mode 100644 index 0000000000..152442bc5b --- /dev/null +++ b/browser/components/search/test/marionette/manifest.toml @@ -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/domain_category_mappings_1a.json b/browser/components/search/test/unit/domain_category_mappings_1a.json new file mode 100644 index 0000000000..51b18e12a7 --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_1a.json @@ -0,0 +1,3 @@ +{ + "Wrq9YDsieAMC3Y2DSY5Rcg==": [1, 100] +} diff --git a/browser/components/search/test/unit/domain_category_mappings_1b.json b/browser/components/search/test/unit/domain_category_mappings_1b.json new file mode 100644 index 0000000000..698ef45f1a --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_1b.json @@ -0,0 +1,3 @@ +{ + "G99y4E1rUMgqSMfk3TjMaQ==": [2, 90] +} diff --git a/browser/components/search/test/unit/domain_category_mappings_2a.json b/browser/components/search/test/unit/domain_category_mappings_2a.json new file mode 100644 index 0000000000..08db2fa8c2 --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_2a.json @@ -0,0 +1,3 @@ +{ + "Wrq9YDsieAMC3Y2DSY5Rcg==": [1, 80] +} diff --git a/browser/components/search/test/unit/domain_category_mappings_2b.json b/browser/components/search/test/unit/domain_category_mappings_2b.json new file mode 100644 index 0000000000..dec2d130c1 --- /dev/null +++ b/browser/components/search/test/unit/domain_category_mappings_2b.json @@ -0,0 +1,3 @@ +{ + "G99y4E1rUMgqSMfk3TjMaQ==": [2, 50, 4, 80] +} diff --git a/browser/components/search/test/unit/test_search_telemetry_categorization_logic.js b/browser/components/search/test/unit/test_search_telemetry_categorization_logic.js new file mode 100644 index 0000000000..947a7aae46 --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_categorization_logic.js @@ -0,0 +1,346 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This test ensures we are correctly applying the SERP categorization logic to + * the domains that have been extracted from the SERP. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetryUtils: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE = { + "byVQ4ej7T7s2xf/cPqgMyw==": [2, 90], + "1TEnSjgNCuobI6olZinMiQ==": [2, 95], + "/Bnju09b9iBPjg7K+5ENIw==": [2, 78, 4, 10], + "Ja6RJq5LQftdl7NQrX1avQ==": [2, 56, 4, 24], + "Jy26Qt99JrUderAcURtQ5A==": [2, 89], + "sZnJyyzY9QcN810Q6jfbvw==": [2, 43], + "QhmteGKeYk0okuB/bXzwRw==": [2, 65], + "CKQZZ1IJjzjjE4LUV8vUSg==": [2, 67], + "FK7mL5E1JaE6VzOiGMmlZg==": [2, 89], + "mzcR/nhDcrs0ed4kTf+ZFg==": [2, 99], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_INCONCLUSIVE = { + "IkOfhoSlHTMIZzWXkYf7fg==": [0, 0], + "PIAHxeaBOeDNY2tvZKqQuw==": [0, 0], + "DKx2mqmFtEvxrHAqpwSevA==": [0, 0], + "DlZKnz9ryYqbxJq9wodzlA==": [0, 0], + "n3NWT4N9JlKX0I7MUtAsYg==": [0, 0], + "A6KyupOlu5zXt8loti90qw==": [0, 0], + "gf5rpseruOaq8nXOSJPG3Q==": [0, 0], + "vlQYOvbcbAp6sMx54OwqCQ==": [0, 0], + "8PcaPATLgmHD9SR0/961Sw==": [0, 0], + "l+hLycEAW2v/OPE/XFpNwQ==": [0, 0], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_UNKNOWN_AND_INCONCLUSIVE = { + "CEA642T3hV+Fdi2PaRH9BQ==": [0, 0], + "cVqopYLASYxcWdDW4F+w2w==": [0, 0], + "X61OdTU20n8pxZ76K2eAHg==": [0, 0], + "/srrOggOAwgaBGCsPdC4bA==": [0, 0], + "onnMGn+MmaCQx3RNLBzGOQ==": [0, 0], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_ALL_TYPES = { + "VSXaqgDKYWrJ/yjsFomUdg==": [3, 90], + "6re74Kk34n2V6VCdLmCD5w==": [3, 88], + "s8gOGIaFnly5hHX7nPncnw==": [3, 90, 6, 2], + "zfRJyKV+2jd1RKNsSHm9pw==": [3, 78, 6, 7], + "zcW+KbRfLRO6Dljf5qnuwQ==": [3, 97], + "Rau9mfbBcIRiRQIliUxkow==": [0, 0], + "4AFhUOmLQ8804doOsI4jBA==": [0, 0], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_TIE = { + "fmEqRSc+pBr9noi0l99nGw==": [1, 50, 2, 50], + "cms8ipz0JQ3WS9o48RtvnQ==": [1, 50, 2, 50], + "y8Haj7Qdmx+k762RaxCPvA==": [1, 50, 2, 50], + "tCbLmi5xJ/OrF8tbRm8PrA==": [1, 50, 2, 50], + "uYNQECmDShqI409HrSTdLQ==": [1, 50, 2, 50], + "D88hdsmzLWIXYhkrDal33w==": [3, 50, 4, 50], + "1mhx0I0B4cEaI91x8zor7Q==": [5, 50, 6, 50], + "dVZYATQixuBHmalCFR9+Lw==": [7, 50, 8, 50], + "pdOFJG49D7hE/+FtsWDihQ==": [9, 50, 10, 50], + "+gl+dBhWE0nx0AM69m2g5w==": [11, 50, 12, 50], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_1 = { + "VSXaqgDKYWrJ/yjsFomUdg==": [1, 45], + "6re74Kk34n2V6VCdLmCD5w==": [2, 45], + "s8gOGIaFnly5hHX7nPncnw==": [3, 45], + "zfRJyKV+2jd1RKNsSHm9pw==": [4, 45], + "zcW+KbRfLRO6Dljf5qnuwQ==": [5, 45], + "Rau9mfbBcIRiRQIliUxkow==": [6, 45], + "4AFhUOmLQ8804doOsI4jBA==": [7, 45], + "YZ3aEL73MR+Cjog0D7A24w==": [8, 45], + "crMclD9rwInEQ30DpZLg+g==": [9, 45], + "/r7oPRoE6LJAE95nuwmu7w==": [10, 45], +}; + +const TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_2 = { + "sHWSmFwSYL3snycBZCY8Kg==": [1, 35, 2, 4], + "FZ5zPYh6ByI0KGWKkmpDoA==": [1, 5, 2, 94], +}; + +add_setup(async () => { + Services.prefs.setBoolPref( + "browser.search.serpEventTelemetryCategorization.enabled", + true + ); +}); + +add_task(async function test_categorization_simple() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE + ); + + let domains = new Set([ + "test1.com", + "test2.com", + "test3.com", + "test4.com", + "test5.com", + "test6.com", + "test7.com", + "test8.com", + "test9.com", + "test10.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { category: "2", num_domains: 10, num_inconclusive: 0, num_unknown: 0 }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_inconclusive() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_INCONCLUSIVE + ); + + let domains = new Set([ + "test11.com", + "test12.com", + "test13.com", + "test14.com", + "test15.com", + "test16.com", + "test17.com", + "test18.com", + "test19.com", + "test20.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE, + num_domains: 10, + num_inconclusive: 10, + num_unknown: 0, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_unknown() { + // Reusing TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE since none of this task's + // domains will be keys within it. + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_SIMPLE + ); + + let domains = new Set([ + "test21.com", + "test22.com", + "test23.com", + "test24.com", + "test25.com", + "test26.com", + "test27.com", + "test28.com", + "test29.com", + "test30.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE, + num_domains: 10, + num_inconclusive: 0, + num_unknown: 10, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_unknown_and_inconclusive() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_UNKNOWN_AND_INCONCLUSIVE + ); + + let domains = new Set([ + "test31.com", + "test32.com", + "test33.com", + "test34.com", + "test35.com", + "test36.com", + "test37.com", + "test38.com", + "test39.com", + "test40.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE, + num_domains: 10, + num_inconclusive: 5, + num_unknown: 5, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +// Tests a mixture of categorized, inconclusive and unknown domains. +add_task(async function test_categorization_all_types() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_ALL_TYPES + ); + + // First 5 domains are categorized, 6th and 7th are inconclusive and the last + // 3 are unknown. + let domains = new Set([ + "test51.com", + "test52.com", + "test53.com", + "test54.com", + "test55.com", + "test56.com", + "test57.com", + "test58.com", + "test59.com", + "test60.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { + category: "3", + num_domains: 10, + num_inconclusive: 2, + num_unknown: 3, + }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_categorization_tie() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_TIE + ); + + let domains = new Set([ + "test41.com", + "test42.com", + "test43.com", + "test44.com", + "test45.com", + "test46.com", + "test47.com", + "test48.com", + "test49.com", + "test50.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.equal( + [1, 2].includes(resultsToReport.category), + true, + "Category should be one of the 2 categories with the max score." + ); + delete resultsToReport.category; + Assert.deepEqual( + resultsToReport, + { + num_domains: 10, + num_inconclusive: 0, + num_unknown: 0, + }, + "Should report the correct counts for the various domain types." + ); +}); + +add_task(async function test_rank_penalization_equal_scores() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_1 + ); + + let domains = new Set([ + "test51.com", + "test52.com", + "test53.com", + "test54.com", + "test55.com", + "test56.com", + "test57.com", + "test58.com", + "test59.com", + "test60.com", + ]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { category: "1", num_domains: 10, num_inconclusive: 0, num_unknown: 0 }, + "Should report the correct values for categorizing the SERP." + ); +}); + +add_task(async function test_rank_penalization_highest_score_lower_on_page() { + SearchSERPDomainToCategoriesMap.overrideMapForTests( + TEST_DOMAIN_TO_CATEGORIES_MAP_RANK_PENALIZATION_2 + ); + + let domains = new Set(["test61.com", "test62.com"]); + + let resultsToReport = + SearchSERPCategorization.applyCategorizationLogic(domains); + + Assert.deepEqual( + resultsToReport, + { category: "2", num_domains: 2, num_inconclusive: 0, num_unknown: 0 }, + "Should report the correct values for categorizing the SERP." + ); +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js b/browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js new file mode 100644 index 0000000000..84acedaa7a --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * This test ensures we are correctly processing the domains that have been + * extracted from a SERP. + */ + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + SearchSERPCategorization: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +// Links including the provider name are not extracted. +const PROVIDER = "example"; + +const TESTS = [ + { + title: "Domains matching the provider.", + domains: ["example.com", "www.example.com", "www.foobar.com"], + expected: ["foobar.com"], + }, + { + title: "Second-level domains to a top-level domain.", + domains: [ + "www.foobar.gc.ca", + "www.foobar.gov.uk", + "foobar.co.uk", + "www.foobar.co.il", + ], + expected: ["foobar.gc.ca", "foobar.gov.uk", "foobar.co.uk", "foobar.co.il"], + }, + { + title: "Long subdomain.", + domains: ["ab.cd.ef.gh.foobar.com"], + expected: ["foobar.com"], + }, + { + title: "Same top-level domain.", + domains: ["foobar.com", "www.foobar.com", "abc.def.foobar.com"], + expected: ["foobar.com"], + }, + { + title: "Empty input.", + domains: [""], + expected: [], + }, +]; + +add_setup(async function () { + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + "serpEventTelemetry.enabled", + true + ); + Services.prefs.setBoolPref( + SearchUtils.BROWSER_SEARCH_PREF + + "serpEventTelemetryCategorization.enabled", + true + ); + + // Required or else BrowserSearchTelemetry will throw. + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); + await SearchSERPTelemetry.init(); +}); + +add_task(async function test_parsing_extracted_urls() { + for (let i = 0; i < TESTS.length; i++) { + let currentTest = TESTS[i]; + let domains = new Set(currentTest.domains); + + if (currentTest.title) { + info(currentTest.title); + } + let expectedDomains = new Set(currentTest.expected); + let actualDomains = SearchSERPCategorization.processDomains( + domains, + PROVIDER + ); + + Assert.deepEqual( + Array.from(actualDomains), + Array.from(expectedDomains), + "Domains should have been parsed correctly." + ); + } +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js b/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js new file mode 100644 index 0000000000..423ee0a81d --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_categorization_sync.js @@ -0,0 +1,423 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Tests the integration of Remote Settings with SERP domain categorization. + */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SearchSERPDomainToCategoriesMap: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + TELEMETRY_CATEGORIZATION_KEY: + "resource:///modules/SearchSERPTelemetry.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +async function waitForDomainToCategoriesUpdate() { + return TestUtils.topicObserved("domain-to-categories-map-update-complete"); +} + +async function mockRecordWithCachedAttachment({ id, version, filename }) { + // Get the bytes of the file for the hash and size for attachment metadata. + let data = await IOUtils.readUTF8( + PathUtils.join(do_get_cwd().path, filename) + ); + let buffer = new TextEncoder().encode(data).buffer; + let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance( + Ci.nsIArrayBufferInputStream + ); + stream.setData(buffer, 0, buffer.byteLength); + + // Generate a hash. + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + hasher.updateFromStream(stream, -1); + let hash = hasher.finish(false); + hash = Array.from(hash, (_, i) => + ("0" + hash.charCodeAt(i).toString(16)).slice(-2) + ).join(""); + + let record = { + id, + version, + attachment: { + hash, + location: `main-workspace/search-categorization/${filename}`, + filename, + size: buffer.byteLength, + mimetype: "application/json", + }, + }; + + client.attachments.cacheImpl.set(id, { + record, + blob: new Blob([buffer]), + }); + + return record; +} + +const RECORD_A_ID = Services.uuid.generateUUID().number.slice(1, -1); +const RECORD_B_ID = Services.uuid.generateUUID().number.slice(1, -1); + +const client = RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); +const db = client.db; + +const RECORDS = { + record1a: { + id: RECORD_A_ID, + version: 1, + filename: "domain_category_mappings_1a.json", + }, + record1b: { + id: RECORD_B_ID, + version: 1, + filename: "domain_category_mappings_1b.json", + }, + record2a: { + id: RECORD_A_ID, + version: 2, + filename: "domain_category_mappings_2a.json", + }, + record2b: { + id: RECORD_B_ID, + version: 2, + filename: "domain_category_mappings_2b.json", + }, +}; + +add_setup(async () => { + // Testing with Remote Settings requires a profile. + do_get_profile(); + await db.clear(); +}); + +add_task(async function test_initial_import() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Create record containing domain_category_mappings_1b.json attachment."); + let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); + await db.create(record1b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 100 }], + "Return value from lookup of example.com should be the same." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [{ category: 2, score: 90 }], + "Return value from lookup of example.org should be the same." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_update_records() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Create record containing domain_category_mappings_1b.json attachment."); + let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); + await db.create(record1b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + info("Send update from Remote Settings with updates to attachments."); + let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); + let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); + const payload = { + current: [record2a, record2b], + created: [], + updated: [ + { old: record1a, new: record2a }, + { old: record1b, new: record2b }, + ], + deleted: [], + }; + promise = waitForDomainToCategoriesUpdate(); + await client.emit("sync", { + data: payload, + }); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 80 }], + "Return value from lookup of example.com should have changed." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [ + { category: 2, score: 50 }, + { category: 4, score: 80 }, + ], + "Return value from lookup of example.org should have changed." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be correct." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_delayed_initial_import() { + info("Initialize search categorization mappings."); + let observeNoRecordsFound = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes( + "No records found for domain-to-categories map." + ) + ); + }); + info("Initialize without records."); + await SearchSERPDomainToCategoriesMap.init(); + await observeNoRecordsFound; + + Assert.ok(SearchSERPDomainToCategoriesMap.empty, "Map is empty."); + + info("Send update from Remote Settings with updates to attachments."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + let record1b = await mockRecordWithCachedAttachment(RECORDS.record1b); + const payload = { + current: [record1a, record1b], + created: [record1a, record1b], + updated: [], + deleted: [], + }; + let promise = waitForDomainToCategoriesUpdate(); + await client.emit("sync", { + data: payload, + }); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 100 }], + "Return value from lookup of example.com should be the same." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [{ category: 2, score: 90 }], + "Return value from lookup of example.org should be the same." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 1, + "Version should be correct." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_remove_record() { + info("Create record containing domain_category_mappings_2a.json attachment."); + let record2a = await mockRecordWithCachedAttachment(RECORDS.record2a); + await db.create(record2a); + + info("Create record containing domain_category_mappings_2b.json attachment."); + let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); + await db.create(record2b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 80 }], + "Initialized properly." + ); + + info("Send update from Remote Settings with one removed record."); + const payload = { + current: [record2a], + created: [], + updated: [], + deleted: [record2b], + }; + promise = waitForDomainToCategoriesUpdate(); + await client.emit("sync", { + data: payload, + }); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [{ category: 1, score: 80 }], + "Return value from lookup of example.com should remain unchanged." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [], + "Return value from lookup of example.org should be empty." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be correct." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_different_versions_coexisting() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Create record containing domain_category_mappings_2b.json attachment."); + let record2b = await mockRecordWithCachedAttachment(RECORDS.record2b); + await db.create(record2b); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [ + { + category: 1, + score: 100, + }, + ], + "Should have a record from an older version." + ); + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.org"), + [ + { category: 2, score: 50 }, + { category: 4, score: 80 }, + ], + "Return value from lookup of example.org should have the most recent value." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 2, + "Version should be the latest." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); + +add_task(async function test_download_error() { + info("Create record containing domain_category_mappings_1a.json attachment."); + let record1a = await mockRecordWithCachedAttachment(RECORDS.record1a); + await db.create(record1a); + + info("Add data to Remote Settings DB."); + await db.importChanges({}, Date.now()); + + info("Initialize search categorization mappings."); + let promise = waitForDomainToCategoriesUpdate(); + await SearchSERPDomainToCategoriesMap.init(); + await promise; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [ + { + category: 1, + score: 100, + }, + ], + "Domain should have an entry in the map." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + 1, + "Version should be present." + ); + + info("Delete attachment from local cache."); + client.attachments.cacheImpl.delete(RECORD_A_ID); + + const payload = { + current: [record1a], + created: [], + updated: [record1a], + deleted: [], + }; + + info("Sync payload."); + let observeDownloadError = TestUtils.consoleMessageObserved(msg => { + return ( + typeof msg.wrappedJSObject.arguments?.[0] == "string" && + msg.wrappedJSObject.arguments[0].includes("Could not download file:") + ); + }); + await client.emit("sync", { + data: payload, + }); + await observeDownloadError; + + Assert.deepEqual( + SearchSERPDomainToCategoriesMap.get("example.com"), + [], + "Domain should not exist in store." + ); + + Assert.equal( + SearchSERPDomainToCategoriesMap.version, + null, + "Version should remain null." + ); + + // Clean up. + await db.clear(); + SearchSERPDomainToCategoriesMap.uninit(); +}); diff --git a/browser/components/search/test/unit/test_search_telemetry_compare_urls.js b/browser/components/search/test/unit/test_search_telemetry_compare_urls.js new file mode 100644 index 0000000000..c99c28607a --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_compare_urls.js @@ -0,0 +1,188 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* + * This test ensures we compare URLs correctly. For more info on the scores, + * please read the function definition. + */ + +ChromeUtils.defineESModuleGetters(this, { + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", +}); + +const TESTS = [ + { + title: "No difference", + url1: "https://www.example.org/search?a=b&c=d#hash", + url2: "https://www.example.org/search?a=b&c=d#hash", + expected: Infinity, + }, + { + // Since the ordering is different, a strict equality match is not going + // match. The score will be high, but not Infinity. + title: "Different ordering of query parameters", + url1: "https://www.example.org/search?c=d&a=b#hash", + url2: "https://www.example.org/search?a=b&c=d#hash", + expected: 7, + }, + { + title: "Different protocol", + url1: "http://www.example.org/search", + url2: "https://www.example.org/search", + expected: 0, + }, + { + title: "Different origin", + url1: "https://example.org/search", + url2: "https://www.example.org/search", + expected: 0, + }, + { + title: "Different path", + url1: "https://www.example.org/serp", + url2: "https://www.example.org/search", + expected: 1, + }, + { + title: "Different path, path on", + url1: "https://www.example.org/serp", + url2: "https://www.example.org/search", + options: { + path: true, + }, + expected: 0, + }, + { + title: "Different query parameter keys", + url1: "https://www.example.org/search?a=c", + url2: "https://www.example.org/search?b=c", + expected: 3, + }, + { + title: "Different query parameter keys, paramValues on", + url1: "https://www.example.org/search?a=c", + url2: "https://www.example.org/search?b=c", + options: { + paramValues: true, + }, + // Shouldn't change the score because the option should only nullify + // the result if one of the keys match but has different values. + expected: 3, + }, + { + title: "Some different query parameter keys", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b", + expected: 5, + }, + { + title: "Some different query parameter keys, paramValues on", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b", + options: { + paramValues: true, + }, + // Shouldn't change the score because the option should only trigger + // if the keys match but values differ. + expected: 5, + }, + { + title: "Different query parameter values", + url1: "https://www.example.org/search?a=b", + url2: "https://www.example.org/search?a=c", + expected: 4, + }, + { + title: "Different query parameter values, paramValues on", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b&c=e", + options: { + paramValues: true, + }, + expected: 0, + }, + { + title: "Some different query parameter values", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b&c=e", + expected: 6, + }, + { + title: "Different query parameter values, paramValues on", + url1: "https://www.example.org/search?a=b&c=d", + url2: "https://www.example.org/search?a=b&c=e", + options: { + paramValues: true, + }, + expected: 0, + }, + { + title: "Empty query parameter", + url1: "https://www.example.org/search?a=b&c", + url2: "https://www.example.org/search?c&a=b", + expected: 7, + }, + { + title: "Empty query parameter, paramValues on", + url1: "https://www.example.org/search?a=b&c", + url2: "https://www.example.org/search?c&a=b", + options: { + paramValues: true, + }, + expected: 7, + }, + { + title: "Missing empty query parameter", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b", + expected: 5, + }, + { + title: "Missing empty query parameter, paramValues on", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b", + options: { + paramValues: true, + }, + expected: 5, + }, + { + title: "Different empty query parameter", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b&c=foo", + expected: 6, + }, + { + title: "Different empty query parameter, paramValues on", + url1: "https://www.example.org/search?c&a=b", + url2: "https://www.example.org/search?a=b&c=foo", + options: { + paramValues: true, + }, + expected: 0, + }, +]; + +add_setup(async function () { + await SearchSERPTelemetry.init(); +}); + +add_task(async function test_parsing_extracted_urls() { + for (let test of TESTS) { + info(test.title); + let result = SearchSERPTelemetry.compareUrls( + new URL(test.url1), + new URL(test.url2), + test.options + ); + Assert.equal(result, test.expected, "Equality: url1, url2"); + + // Flip the URLs to ensure order doesn't matter. + result = SearchSERPTelemetry.compareUrls( + new URL(test.url2), + new URL(test.url1), + test.options + ); + Assert.equal(result, test.expected, "Equality: url2, url1"); + } +}); 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..8897b1e7c7 --- /dev/null +++ b/browser/components/search/test/unit/test_search_telemetry_config_validation.js @@ -0,0 +1,137 @@ +/* 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", + SearchEngineSelector: "resource://gre/modules/SearchEngineSelector.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_telemetry_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); + } +}); + +add_task(async function test_search_config_codes_in_search_telemetry() { + let searchTelemetry = await RemoteSettings(TELEMETRY_SETTINGS_KEY).get(); + + let selector = new SearchEngineSelector(() => {}); + let searchConfig = await selector.getEngineConfiguration(); + + const telemetryIdToSearchEngineIdMap = new Map([["duckduckgo", "ddg"]]); + + for (let telemetryEntry of searchTelemetry) { + info(`Checking: ${telemetryEntry.telemetryId}`); + let engine; + for (let entry of searchConfig) { + if (entry.recordType != "engine") { + continue; + } + if ( + entry.identifier == telemetryEntry.telemetryId || + entry.identifier == + telemetryIdToSearchEngineIdMap.get(telemetryEntry.telemetryId) + ) { + engine = entry; + } + } + Assert.ok( + !!engine, + `Should have associated engine data for telemetry id ${telemetryEntry.telemetryId}` + ); + + if (engine.base.partnerCode) { + Assert.ok( + telemetryEntry.taggedCodes.includes(engine.base.partnerCode), + `Should have the base partner code ${engine.base.partnerCode} listed in the search telemetry 'taggedCodes'` + ); + } else { + Assert.equal( + telemetryEntry.telemetryId, + "baidu", + "Should only not have a base partner code for Baidu" + ); + } + + if (engine.variants) { + for (let variant of engine.variants) { + if ("partnerCode" in variant) { + Assert.ok( + telemetryEntry.taggedCodes.includes(variant.partnerCode), + `Should have the partner code ${variant.partnerCode} listed in the search telemetry 'taggedCodes'` + ); + } + } + } + } +}); 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..07f2407015 --- /dev/null +++ b/browser/components/search/test/unit/test_urlTelemetry.js @@ -0,0 +1,306 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +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() { + await SearchSERPTelemetry.init(); + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); + // There is no concept of browsing in unit tests, so assume in tests that we + // are not in private browsing mode. We have browser tests that check when + // private browsing is used. + sinon.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); +}); + +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..e967002421 --- /dev/null +++ b/browser/components/search/test/unit/test_urlTelemetry_generic.js @@ -0,0 +1,329 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +ChromeUtils.defineESModuleGetters(this, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.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", +}); + +const TEST_PROVIDER_INFO = [ + { + telemetryId: "example", + searchPageRegexp: /^https:\/\/www\.example\.com\/search/, + queryParamNames: ["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/, + queryParamNames: ["a", "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", + is_private: "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", + is_private: "false", + 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", + is_private: "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", + is_private: "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", + is_private: "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", + is_private: "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", + is_private: "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", + is_private: "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", + is_private: "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 + "serpEventTelemetry.enabled", + true + ); + Services.fog.initializeFOG(); + await SearchSERPTelemetry.init(); + SearchSERPTelemetry.overrideSearchTelemetryForTests(TEST_PROVIDER_INFO); + sinon.stub(BrowserSearchTelemetry, "shouldRecordSearchCount").returns(true); + // There is no concept of browsing in unit tests, so assume in tests that we + // are not in private browsing mode. We have browser tests that check when + // private browsing is used. + sinon.stub(PrivateBrowsingUtils, "isBrowserPrivate").returns(false); +}); + +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.toml b/browser/components/search/test/unit/xpcshell.toml new file mode 100644 index 0000000000..61cdb83378 --- /dev/null +++ b/browser/components/search/test/unit/xpcshell.toml @@ -0,0 +1,29 @@ +[DEFAULT] +support-files = [ + "../../../../../services/settings/dumps/main/search-config-v2.json", +] +prefs = ["browser.search.log=true"] +skip-if = ["os == 'android'"] # bug 1730213 +firefox-appdir = "browser" + +["test_search_telemetry_categorization_logic.js"] + +["test_search_telemetry_categorization_process_domains.js"] + +["test_search_telemetry_categorization_sync.js"] +prefs = ["browser.search.serpEventTelemetryCategorization.enabled=true"] +support-files = [ + "domain_category_mappings_1a.json", + "domain_category_mappings_1b.json", + "domain_category_mappings_2a.json", + "domain_category_mappings_2b.json", +] + +["test_search_telemetry_compare_urls.js"] + +["test_search_telemetry_config_validation.js"] +support-files = ["../../schema/search-telemetry-schema.json"] + +["test_urlTelemetry.js"] + +["test_urlTelemetry_generic.js"] |