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 | |
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')
422 files changed, 37898 insertions, 0 deletions
diff --git a/browser/components/search/.eslintrc.js b/browser/components/search/.eslintrc.js new file mode 100644 index 0000000000..39079432e7 --- /dev/null +++ b/browser/components/search/.eslintrc.js @@ -0,0 +1,13 @@ +/* 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/. */ + +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/require-jsdoc"], + + rules: { + "mozilla/var-only-at-top-level": "error", + }, +}; diff --git a/browser/components/search/BrowserSearchTelemetry.sys.mjs b/browser/components/search/BrowserSearchTelemetry.sys.mjs new file mode 100644 index 0000000000..469167cbf4 --- /dev/null +++ b/browser/components/search/BrowserSearchTelemetry.sys.mjs @@ -0,0 +1,328 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchSERPTelemetry: "resource:///modules/SearchSERPTelemetry.sys.mjs", + UrlbarSearchUtils: "resource:///modules/UrlbarSearchUtils.sys.mjs", +}); + +// `contextId` is a unique identifier used by Contextual Services +const CONTEXT_ID_PREF = "browser.contextual-services.contextId"; +ChromeUtils.defineLazyGetter(lazy, "contextId", () => { + let _contextId = Services.prefs.getStringPref(CONTEXT_ID_PREF, null); + if (!_contextId) { + _contextId = Services.uuid.generateUUID().toString(); + Services.prefs.setStringPref(CONTEXT_ID_PREF, _contextId); + } + return _contextId; +}); + +// A map of known search origins. +// The keys of this map are used in the calling code to recordSearch, and in +// the SEARCH_COUNTS histogram. +// The values of this map are used in the names of scalars for the following +// scalar groups: +// browser.engagement.navigation.* +// browser.search.content.* +// browser.search.withads.* +// browser.search.adclicks.* +const KNOWN_SEARCH_SOURCES = new Map([ + ["abouthome", "about_home"], + ["contextmenu", "contextmenu"], + ["newtab", "about_newtab"], + ["searchbar", "searchbar"], + ["system", "system"], + ["urlbar", "urlbar"], + ["urlbar-handoff", "urlbar_handoff"], + ["urlbar-persisted", "urlbar_persisted"], + ["urlbar-searchmode", "urlbar_searchmode"], + ["webextension", "webextension"], +]); + +/** + * This class handles saving search telemetry related to the url bar, + * search bar and other areas as per the sources above. + */ +class BrowserSearchTelemetryHandler { + KNOWN_SEARCH_SOURCES = KNOWN_SEARCH_SOURCES; + + /** + * Determines if we should record a search for this browser instance. + * Private Browsing mode is normally skipped. + * + * @param {browser} browser + * The browser where the search was loaded. + * @returns {boolean} + * True if the search should be recorded, false otherwise. + */ + shouldRecordSearchCount(browser) { + return ( + !lazy.PrivateBrowsingUtils.isWindowPrivate(browser.ownerGlobal) || + !Services.prefs.getBoolPref("browser.engagement.search_counts.pbm", false) + ); + } + + /** + * Records the method by which the user selected a result from the urlbar or + * searchbar. + * + * @param {Event} event + * The event that triggered the selection. + * @param {string} source + * Either "urlbar" or "searchbar" depending on the source. + * @param {number} index + * The index that the user chose in the popup, or -1 if there wasn't a + * selection. + * @param {string} userSelectionBehavior + * How the user cycled through results before picking the current match. + * Could be one of "tab", "arrow" or "none". + */ + recordSearchSuggestionSelectionMethod( + event, + source, + index, + userSelectionBehavior = "none" + ) { + // If the contents of the histogram are changed then + // `UrlbarTestUtils.SELECTED_RESULT_METHODS` should also be updated. + if (source == "searchbar" && userSelectionBehavior != "none") { + throw new Error("Did not expect a selection behavior for the searchbar."); + } + + let histogram = Services.telemetry.getHistogramById( + source == "urlbar" + ? "FX_URLBAR_SELECTED_RESULT_METHOD" + : "FX_SEARCHBAR_SELECTED_RESULT_METHOD" + ); + // command events are from the one-off context menu. Treat them as clicks. + // Note that we don't care about MouseEvent subclasses here, since + // those are not clicks. + let isClick = + event && + (ChromeUtils.getClassName(event) == "MouseEvent" || + event.type == "command"); + let category; + if (isClick) { + category = "click"; + } else if (index >= 0) { + switch (userSelectionBehavior) { + case "tab": + category = "tabEnterSelection"; + break; + case "arrow": + category = "arrowEnterSelection"; + break; + case "rightClick": + // Selected by right mouse button. + category = "rightClickEnter"; + break; + default: + category = "enterSelection"; + } + } else { + category = "enter"; + } + histogram.add(category); + } + + /** + * Records entry into the Urlbar's search mode. + * + * Telemetry records only which search mode is entered and how it was entered. + * It does not record anything pertaining to searches made within search mode. + * + * @param {object} searchMode + * A search mode object. See UrlbarInput.setSearchMode documentation for + * details. + */ + recordSearchMode(searchMode) { + // Search mode preview is not search mode. Recording it would just create + // noise. + if (searchMode.isPreview) { + return; + } + + let scalarKey = lazy.UrlbarSearchUtils.getSearchModeScalarKey(searchMode); + Services.telemetry.keyedScalarAdd( + "urlbar.searchmode." + searchMode.entry, + scalarKey, + 1 + ); + } + + /** + * The main entry point for recording search related Telemetry. This includes + * search counts and engagement measurements. + * + * Telemetry records only search counts per engine and action origin, but + * nothing pertaining to the search contents themselves. + * + * @param {browser} browser + * The browser where the search originated. + * @param {nsISearchEngine} engine + * The engine handling the search. + * @param {string} source + * Where the search originated from. See KNOWN_SEARCH_SOURCES for allowed + * values. + * @param {object} [details] Options object. + * @param {boolean} [details.isOneOff=false] + * true if this event was generated by a one-off search. + * @param {boolean} [details.isSuggestion=false] + * true if this event was generated by a suggested search. + * @param {boolean} [details.isFormHistory=false] + * true if this event was generated by a form history result. + * @param {string} [details.alias=null] + * The search engine alias used in the search, if any. + * @param {string} [details.newtabSessionId=undefined] + * The newtab session that prompted this search, if any. + * @throws if source is not in the known sources list. + */ + recordSearch(browser, engine, source, details = {}) { + if (engine.clickUrl) { + this.#reportSearchInGlean(engine.clickUrl); + } + + try { + if (!this.shouldRecordSearchCount(browser)) { + return; + } + if (!KNOWN_SEARCH_SOURCES.has(source)) { + console.error("Unknown source for search: ", source); + return; + } + + const countIdPrefix = `${engine.telemetryId}.`; + const countIdSource = countIdPrefix + source; + let histogram = Services.telemetry.getKeyedHistogramById("SEARCH_COUNTS"); + + if ( + details.alias && + engine.isAppProvided && + engine.aliases.includes(details.alias) + ) { + // This is a keyword search using an AppProvided engine. + // Record the source as "alias", not "urlbar". + histogram.add(countIdPrefix + "alias"); + } else { + histogram.add(countIdSource); + } + + // Dispatch the search signal to other handlers. + switch (source) { + case "urlbar": + case "searchbar": + case "urlbar-searchmode": + case "urlbar-persisted": + case "urlbar-handoff": + this._handleSearchAndUrlbar(browser, engine, source, details); + break; + case "abouthome": + case "newtab": + this._recordSearch(browser, engine, source, "enter"); + break; + default: + this._recordSearch(browser, engine, source); + break; + } + if (["urlbar-handoff", "abouthome", "newtab"].includes(source)) { + Glean.newtabSearch.issued.record({ + newtab_visit_id: details.newtabSessionId, + search_access_point: KNOWN_SEARCH_SOURCES.get(source), + telemetry_id: engine.telemetryId, + }); + lazy.SearchSERPTelemetry.recordBrowserNewtabSession( + browser, + details.newtabSessionId + ); + } + } catch (ex) { + // Catch any errors here, so that search actions are not broken if + // telemetry is broken for some reason. + console.error(ex); + } + } + + /** + * This function handles the "urlbar", "urlbar-oneoff", "searchbar" and + * "searchbar-oneoff" sources. + * + * @param {browser} browser + * The browser where the search originated. + * @param {nsISearchEngine} engine + * The engine handling the search. + * @param {string} source + * Where the search originated from. + * @param {object} details + * See {@link BrowserSearchTelemetryHandler.recordSearch} + */ + _handleSearchAndUrlbar(browser, engine, source, details) { + const isOneOff = !!details.isOneOff; + let action = "enter"; + if (isOneOff) { + action = "oneoff"; + } else if (details.isFormHistory) { + action = "formhistory"; + } else if (details.isSuggestion) { + action = "suggestion"; + } else if (details.alias) { + action = "alias"; + } + + this._recordSearch(browser, engine, source, action); + } + + _recordSearch(browser, engine, source, action = null) { + let scalarSource = KNOWN_SEARCH_SOURCES.get(source); + + lazy.SearchSERPTelemetry.recordBrowserSource(browser, scalarSource); + + let scalarKey = action ? "search_" + action : "search"; + Services.telemetry.keyedScalarAdd( + "browser.engagement.navigation." + scalarSource, + scalarKey, + 1 + ); + Services.telemetry.recordEvent( + "navigation", + "search", + scalarSource, + action, + { + engine: engine.telemetryId, + } + ); + } + + /** + * Records the search in Glean for contextual services. + * + * @param {string} reportingUrl + * The url to be sent to contextual services. + */ + #reportSearchInGlean(reportingUrl) { + let defaultValuesByGleanKey = { + contextId: lazy.contextId, + }; + + let sendGleanPing = valuesByGleanKey => { + valuesByGleanKey = { ...defaultValuesByGleanKey, ...valuesByGleanKey }; + for (let [gleanKey, value] of Object.entries(valuesByGleanKey)) { + let glean = Glean.searchWith[gleanKey]; + if (value !== undefined && value !== "") { + glean.set(value); + } + } + GleanPings.searchWith.submit(); + }; + + sendGleanPing({ + reportingUrl, + }); + } +} + +export var BrowserSearchTelemetry = new BrowserSearchTelemetryHandler(); diff --git a/browser/components/search/SearchOneOffs.sys.mjs b/browser/components/search/SearchOneOffs.sys.mjs new file mode 100644 index 0000000000..0459af092a --- /dev/null +++ b/browser/components/search/SearchOneOffs.sys.mjs @@ -0,0 +1,1126 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs", +}); + +const EMPTY_ADD_ENGINES = []; + +/** + * Defines the search one-off button elements. These are displayed at the bottom + * of the address bar and search bar. The address bar buttons are a subclass in + * browser/components/urlbar/UrlbarSearchOneOffs.jsm. If you are adding a new + * subclass, see "Methods for subclasses to override" below. + */ +export class SearchOneOffs { + constructor(container) { + this.container = container; + this.window = container.ownerGlobal; + this.document = container.ownerDocument; + + this.container.appendChild( + this.window.MozXULElement.parseXULToFragment( + ` + <hbox class="search-panel-one-offs-header search-panel-header"> + <label class="search-panel-one-offs-header-label" data-l10n-id="search-one-offs-with-title"/> + </hbox> + <box class="search-panel-one-offs-container"> + <hbox class="search-panel-one-offs" role="group"/> + <button class="searchbar-engine-one-off-item search-setting-button" tabindex="-1" data-l10n-id="search-one-offs-change-settings-compact-button"/> + </box> + <box> + <menupopup class="search-one-offs-context-menu"> + <menuitem class="search-one-offs-context-open-in-new-tab" data-l10n-id="search-one-offs-context-open-new-tab"/> + <menuitem class="search-one-offs-context-set-default" data-l10n-id="search-one-offs-context-set-as-default"/> + <menuitem class="search-one-offs-context-set-default-private" data-l10n-id="search-one-offs-context-set-as-default-private"/> + </menupopup> + </box> + ` + ) + ); + + this._popup = null; + this._textbox = null; + + this._textboxWidth = 0; + + /** + * Set this to a string that identifies your one-offs consumer. It'll + * be appended to telemetry recorded with maybeRecordTelemetry(). + */ + this.telemetryOrigin = ""; + + this._query = ""; + + this._selectedButton = null; + + this.buttons = this.querySelector(".search-panel-one-offs"); + + this.header = this.querySelector(".search-panel-one-offs-header"); + + this.settingsButton = this.querySelector(".search-setting-button"); + + this.contextMenuPopup = this.querySelector(".search-one-offs-context-menu"); + + this._engineInfo = null; + + /** + * `_rebuild()` is async, because it queries the Search Service, which means + * there is a potential for a race when it's called multiple times in succession. + */ + this._rebuilding = false; + + this.addEventListener("mousedown", this); + this.addEventListener("click", this); + this.addEventListener("command", this); + this.addEventListener("contextmenu", this); + + // Prevent popup events from the context menu from reaching the autocomplete + // binding (or other listeners). + let listener = aEvent => aEvent.stopPropagation(); + this.contextMenuPopup.addEventListener("popupshowing", listener); + this.contextMenuPopup.addEventListener("popuphiding", listener); + this.contextMenuPopup.addEventListener("popupshown", aEvent => { + aEvent.stopPropagation(); + }); + this.contextMenuPopup.addEventListener("popuphidden", aEvent => { + aEvent.stopPropagation(); + }); + + // Add weak referenced observers to invalidate our cached list of engines. + this.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]); + Services.obs.addObserver(this, "browser-search-engine-modified", true); + Services.obs.addObserver(this, "browser-search-service", true); + + // Rebuild the buttons when the theme changes. See bug 1357800 for + // details. Summary: On Linux, switching between themes can cause a row + // of buttons to disappear. + Services.obs.addObserver(this, "lightweight-theme-changed", true); + + // This defaults to false in the Search Bar, subclasses can change their + // default in the constructor. + this.disableOneOffsHorizontalKeyNavigation = false; + } + + addEventListener(...args) { + this.container.addEventListener(...args); + } + + removeEventListener(...args) { + this.container.removeEventListener(...args); + } + + dispatchEvent(...args) { + this.container.dispatchEvent(...args); + } + + getAttribute(...args) { + return this.container.getAttribute(...args); + } + + hasAttribute(...args) { + return this.container.hasAttribute(...args); + } + + setAttribute(...args) { + this.container.setAttribute(...args); + } + + querySelector(...args) { + return this.container.querySelector(...args); + } + + handleEvent(event) { + let methodName = "_on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized search-one-offs event: " + event.type); + } + } + + /** + * @returns {boolean} + * True if we will hide the one-offs when they are requested. + */ + async willHide() { + if (this._engineInfo?.willHide !== undefined) { + return this._engineInfo.willHide; + } + let engineInfo = await this.getEngineInfo(); + let oneOffCount = engineInfo.engines.length; + this._engineInfo.willHide = + !oneOffCount || + (oneOffCount == 1 && + engineInfo.engines[0].name == engineInfo.default.name); + return this._engineInfo.willHide; + } + + /** + * Invalidates the engine cache. After invalidating the cache, the one-offs + * will be rebuilt the next time they are shown. + */ + invalidateCache() { + if (!this._rebuilding) { + this._engineInfo = null; + } + } + + /** + * Width in pixels of the one-off buttons. + * NOTE: Used in browser/components/search/content/searchbar.js only. + * + * @returns {number} + */ + get buttonWidth() { + return 48; + } + + /** + * The popup that contains the one-offs. + * + * @param {DOMElement} val + * The new value to set. + */ + set popup(val) { + if (this._popup) { + this._popup.removeEventListener("popupshowing", this); + this._popup.removeEventListener("popuphidden", this); + } + if (val) { + val.addEventListener("popupshowing", this); + val.addEventListener("popuphidden", this); + } + this._popup = val; + + // If the popup is already open, rebuild the one-offs now. The + // popup may be opening, so check that the state is not closed + // instead of checking popupOpen. + if (val && val.state != "closed") { + this._rebuild(); + } + } + + get popup() { + return this._popup; + } + + /** + * The textbox associated with the one-offs. Set this to a textbox to + * automatically keep the related one-offs UI up to date. Otherwise you + * can leave it null/undefined, and in that case you should update the + * query property manually. + * + * @param {DOMElement} val + * The new value to set. + */ + set textbox(val) { + if (this._textbox) { + this._textbox.removeEventListener("input", this); + } + if (val) { + val.addEventListener("input", this); + } + this._textbox = val; + } + + get style() { + return this.container.style; + } + + get textbox() { + return this._textbox; + } + + /** + * The query string currently shown in the one-offs. If the textbox + * property is non-null, then this is automatically updated on + * input. + * + * @param {string} val + * The new query string to set. + */ + set query(val) { + this._query = val; + if (this.isViewOpen) { + let isOneOffSelected = + this.selectedButton && + this.selectedButton.classList.contains( + "searchbar-engine-one-off-item" + ) && + !( + this.selectedButton == this.settingsButton && + this.hasAttribute("is_searchbar") + ); + // Typing de-selects the settings or opensearch buttons at the bottom + // of the search panel, as typing shows the user intends to search. + if (this.selectedButton && !isOneOffSelected) { + this.selectedButton = null; + } + } + } + + get query() { + return this._query; + } + + /** + * The selected one-off including the add-engine button + * and the search-settings button. + * + * @param {DOMElement|null} val + * The selected one-off button. Null if no one-off is selected. + */ + set selectedButton(val) { + let previousButton = this._selectedButton; + if (previousButton) { + previousButton.removeAttribute("selected"); + } + if (val) { + val.toggleAttribute("selected", true); + } + this._selectedButton = val; + + if (this.textbox) { + if (val) { + this.textbox.setAttribute("aria-activedescendant", val.id); + } else { + let active = this.textbox.getAttribute("aria-activedescendant"); + if (active && active.includes("-engine-one-off-item-")) { + this.textbox.removeAttribute("aria-activedescendant"); + } + } + } + + let event = new CustomEvent("SelectedOneOffButtonChanged", { + previousSelectedButton: previousButton, + }); + this.dispatchEvent(event); + } + + get selectedButton() { + return this._selectedButton; + } + + /** + * The index of the selected one-off, including the add-engine button + * and the search-settings button. + * + * @param {number} val + * The new index to set, -1 for nothing selected. + */ + set selectedButtonIndex(val) { + let buttons = this.getSelectableButtons(true); + this.selectedButton = buttons[val]; + } + + get selectedButtonIndex() { + let buttons = this.getSelectableButtons(true); + for (let i = 0; i < buttons.length; i++) { + if (buttons[i] == this._selectedButton) { + return i; + } + } + return -1; + } + + async getEngineInfo() { + if (this._engineInfo) { + return this._engineInfo; + } + + this._engineInfo = {}; + if (lazy.PrivateBrowsingUtils.isWindowPrivate(this.window)) { + this._engineInfo.default = await Services.search.getDefaultPrivate(); + } else { + this._engineInfo.default = await Services.search.getDefault(); + } + + let currentEngineNameToIgnore; + if (!this.getAttribute("includecurrentengine")) { + currentEngineNameToIgnore = this._engineInfo.default.name; + } + + this._engineInfo.engines = ( + await Services.search.getVisibleEngines() + ).filter(e => { + let name = e.name; + return ( + (!currentEngineNameToIgnore || name != currentEngineNameToIgnore) && + !e.hideOneOffButton + ); + }); + + return this._engineInfo; + } + + observe(aEngine, aTopic, aData) { + // For the "browser-search-service" topic, we only need to invalidate + // the cache on initialization complete or when the engines are reloaded. + if (aTopic != "browser-search-service" || aData == "engines-reloaded") { + // Make sure the engine list was updated. + this.invalidateCache(); + } + } + + _getAddEngines() { + return this.window.gBrowser.selectedBrowser.engines || EMPTY_ADD_ENGINES; + } + + get _maxInlineAddEngines() { + return 3; + } + + /** + * Infallible, non-re-entrant version of `__rebuild()`. + */ + async _rebuild() { + if (this._rebuilding) { + return; + } + + this._rebuilding = true; + try { + await this.__rebuild(); + } catch (ex) { + console.error("Search-one-offs::_rebuild() error:", ex); + } finally { + this._rebuilding = false; + this.dispatchEvent(new Event("rebuild")); + } + } + + /** + * Builds all the UI. + */ + async __rebuild() { + // Return early if the list of engines has not changed. + if (!this.popup && this._engineInfo?.domWasUpdated) { + return; + } + + const addEngines = this._getAddEngines(); + + // Return early if the engines and panel width have not changed. + if (this.popup && this._textbox) { + let textboxWidth = await this.window.promiseDocumentFlushed(() => { + return this._textbox.clientWidth; + }); + + if ( + this._engineInfo?.domWasUpdated && + this._textboxWidth == textboxWidth && + this._addEngines == addEngines + ) { + return; + } + this._textboxWidth = textboxWidth; + this._addEngines = addEngines; + } + + const isSearchBar = this.hasAttribute("is_searchbar"); + if (isSearchBar) { + // Hide the container during updating to avoid flickering. + this.container.hidden = true; + } + + // Finally, build the list of one-off buttons. + while (this.buttons.firstElementChild) { + this.buttons.firstElementChild.remove(); + } + + let headerText = this.header.querySelector( + ".search-panel-one-offs-header-label" + ); + headerText.id = this.telemetryOrigin + "-one-offs-header-label"; + this.buttons.setAttribute("aria-labelledby", headerText.id); + + // For the search-bar, always show the one-off buttons where there is an + // option to add an engine. + let addEngineNeeded = isSearchBar && addEngines.length; + let hideOneOffs = (await this.willHide()) && !addEngineNeeded; + + // The _engineInfo cache is used by more consumers, thus it is not a good + // representation of whether this method already updated the one-off buttons + // DOM. For this reason we introduce a separate flag tracking the DOM + // updating, and use it to know when it's okay to not rebuild the one-offs. + // We set this early, since we might either rebuild the DOM or hide it. + this._engineInfo.domWasUpdated = true; + + this.container.hidden = hideOneOffs; + + if (hideOneOffs) { + return; + } + + // Ensure we can refer to the settings buttons by ID: + let origin = this.telemetryOrigin; + this.settingsButton.id = origin + "-anon-search-settings"; + + let engines = (await this.getEngineInfo()).engines; + this._rebuildEngineList(engines, addEngines); + } + + /** + * Adds one-offs for the given engines to the DOM. + * + * @param {Array} engines + * The engines to add. + * @param {Array} addEngines + * The engines that can be added. + */ + _rebuildEngineList(engines, addEngines) { + for (let i = 0; i < engines.length; ++i) { + let engine = engines[i]; + let button = this.document.createXULElement("button"); + button.engine = engine; + button.id = this._buttonIDForEngine(engine); + let iconURL = + engine.getIconURL() || + "chrome://browser/skin/search-engine-placeholder.png"; + button.setAttribute("image", iconURL); + button.setAttribute("class", "searchbar-engine-one-off-item"); + button.setAttribute("tabindex", "-1"); + this.setTooltipForEngineButton(button); + this.buttons.appendChild(button); + } + + for ( + let i = 0, len = Math.min(addEngines.length, this._maxInlineAddEngines); + i < len; + i++ + ) { + const engine = addEngines[i]; + const button = this.document.createXULElement("button"); + button.id = this._buttonIDForEngine(engine); + button.classList.add("searchbar-engine-one-off-item"); + button.classList.add("searchbar-engine-one-off-add-engine"); + button.setAttribute("tabindex", "-1"); + if (engine.icon) { + button.setAttribute("image", engine.icon); + } + this.document.l10n.setAttributes(button, "search-one-offs-add-engine", { + engineName: engine.title, + }); + button.setAttribute("engine-name", engine.title); + button.setAttribute("uri", engine.uri); + this.buttons.appendChild(button); + } + } + + _buttonIDForEngine(engine) { + return ( + this.telemetryOrigin + + "-engine-one-off-item-engine-" + + this._engineInfo.engines.indexOf(engine) + ); + } + + getSelectableButtons(aIncludeNonEngineButtons) { + const buttons = [ + ...this.buttons.querySelectorAll(".searchbar-engine-one-off-item"), + ]; + + if (aIncludeNonEngineButtons) { + buttons.push(this.settingsButton); + } + + return buttons; + } + + /** + * Returns information on where a search results page should be loaded: in the + * current tab or a new tab. + * + * @param {event} aEvent + * The event that triggered the page load. + * @param {boolean} [aForceNewTab] + * True to force the load in a new tab. + * @returns {object} An object { where, params }. `where` is a string: + * "current" or "tab". `params` is an object further describing how + * the page should be loaded. + */ + _whereToOpen(aEvent, aForceNewTab = false) { + let where = "current"; + let params; + // Open ctrl/cmd clicks on one-off buttons in a new background tab. + if (aForceNewTab) { + where = "tab"; + if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) { + params = { + inBackground: true, + }; + } + } else { + let newTabPref = Services.prefs.getBoolPref("browser.search.openintab"); + if ( + (KeyboardEvent.isInstance(aEvent) && aEvent.altKey) ^ newTabPref && + !this.window.gBrowser.selectedTab.isEmpty + ) { + where = "tab"; + } + if ( + MouseEvent.isInstance(aEvent) && + (aEvent.button == 1 || aEvent.getModifierState("Accel")) + ) { + where = "tab"; + params = { + inBackground: true, + }; + } + } + + return { where, params }; + } + + /** + * Increments or decrements the index of the currently selected one-off. + * + * @param {boolean} aForward + * If true, the index is incremented, and if false, the index is + * decremented. + * @param {boolean} aIncludeNonEngineButtons + * If true, buttons that do not have engines are included. + * These buttons include the OpenSearch and settings buttons. For + * example, if the currently selected button is an engine button, + * the next button is the settings button, and you pass true for + * aForward, then passing true for this value would cause the + * settings to be selected. Passing false for this value would + * cause the selection to clear or wrap around, depending on what + * value you passed for the aWrapAround parameter. + * @param {boolean} aWrapAround + * If true, the selection wraps around between the first and last + * buttons. + */ + advanceSelection(aForward, aIncludeNonEngineButtons, aWrapAround) { + let buttons = this.getSelectableButtons(aIncludeNonEngineButtons); + let index; + if (this.selectedButton) { + let inc = aForward ? 1 : -1; + let oldIndex = buttons.indexOf(this.selectedButton); + index = (oldIndex + inc + buttons.length) % buttons.length; + if ( + !aWrapAround && + ((aForward && index <= oldIndex) || (!aForward && oldIndex <= index)) + ) { + // The index has wrapped around, but wrapping around isn't + // allowed. + index = -1; + } + } else { + index = aForward ? 0 : buttons.length - 1; + } + this.selectedButton = index < 0 ? null : buttons[index]; + } + + /** + * This handles key presses specific to the one-off buttons like Tab and + * Alt+Up/Down, and Up/Down keys within the buttons. Since one-off buttons + * are always used in conjunction with a list of some sort (in this.popup), + * it also handles Up/Down keys that cross the boundaries between list + * items and the one-off buttons. + * + * If this method handles the key press, then it will call + * event.preventDefault() and return true. + * + * @param {Event} event + * The key event. + * @param {number} numListItems + * The number of items in the list. The reason that this is a + * parameter at all is that the list may contain items at the end + * that should be ignored, depending on the consumer. That's true + * for the urlbar for example. + * @param {boolean} allowEmptySelection + * Pass true if it's OK that neither the list nor the one-off + * buttons contains a selection. Pass false if either the list or + * the one-off buttons (or both) should always contain a selection. + * @param {string} [textboxUserValue] + * When the last list item is selected and the user presses Down, + * the first one-off becomes selected and the textbox value is + * restored to the value that the user typed. Pass that value here. + * However, if you pass true for allowEmptySelection, you don't need + * to pass anything for this parameter. (Pass undefined or null.) + * @returns {boolean} True if the one-offs handled the key press. + */ + handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) { + if (!this.hasView) { + return false; + } + let handled = this._handleKeyDown( + event, + numListItems, + allowEmptySelection, + textboxUserValue + ); + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + return handled; + } + + _handleKeyDown(event, numListItems, allowEmptySelection, textboxUserValue) { + if (this.container.hidden) { + return false; + } + if ( + event.keyCode == KeyEvent.DOM_VK_RIGHT && + this.selectedButton && + this.selectedButton.classList.contains("addengine-menu-button") + ) { + // If the add-engine overflow menu item is selected and the user + // presses the right arrow key, open the submenu. Unfortunately + // handling the left arrow key -- to close the popup -- isn't + // straightforward. Once the popup is open, it consumes all key + // events. Setting ignorekeys=handled on it doesn't help, since the + // popup handles all arrow keys. Setting ignorekeys=true on it does + // mean that the popup no longer consumes the left arrow key, but + // then it no longer handles up/down keys to select items in the + // popup. + this.selectedButton.open = true; + return true; + } + + // Handle the Tab key, but only if non-Shift modifiers aren't also + // pressed to avoid clobbering other shortcuts (like the Alt+Tab + // browser tab switcher). The reason this uses getModifierState() and + // checks for "AltGraph" is that when you press Shift-Alt-Tab, + // event.altKey is actually false for some reason, at least on macOS. + // getModifierState("Alt") is also false, but "AltGraph" is true. + if ( + event.keyCode == KeyEvent.DOM_VK_TAB && + !event.getModifierState("Alt") && + !event.getModifierState("AltGraph") && + !event.getModifierState("Control") && + !event.getModifierState("Meta") + ) { + if ( + this.getAttribute("disabletab") == "true" || + (event.shiftKey && this.selectedButtonIndex <= 0) || + (!event.shiftKey && + this.selectedButtonIndex == + this.getSelectableButtons(true).length - 1) + ) { + this.selectedButton = null; + return false; + } + this.selectedViewIndex = -1; + this.advanceSelection(!event.shiftKey, true, false); + return !!this.selectedButton; + } + + if (event.keyCode == KeyboardEvent.DOM_VK_UP) { + if (event.altKey) { + // Keep the currently selected result in the list (if any) as a + // secondary "alt" selection and move the selection up within the + // buttons. + this.advanceSelection(false, false, false); + return true; + } + if (numListItems == 0) { + this.advanceSelection(false, true, false); + return true; + } + if (this.selectedViewIndex > 0) { + // Moving up within the list. The autocomplete controller should + // handle this case. A button may be selected, so null it. + this.selectedButton = null; + return false; + } + if (this.selectedViewIndex == 0) { + // Moving up from the top of the list. + if (allowEmptySelection) { + // Let the autocomplete controller remove selection in the list + // and revert the typed text in the textbox. + return false; + } + // Wrap selection around to the last button. + if (this.textbox && typeof textboxUserValue == "string") { + this.textbox.value = textboxUserValue; + } + this.selectedViewIndex = -1; + this.advanceSelection(false, true, true); + return true; + } + if (!this.selectedButton) { + // Moving up from no selection in the list or the buttons, back + // down to the last button. + this.advanceSelection(false, true, true); + return true; + } + if (this.selectedButtonIndex == 0) { + // Moving up from the buttons to the bottom of the list. + this.selectedButton = null; + return false; + } + // Moving up/left within the buttons. + this.advanceSelection(false, true, false); + return true; + } + + if (event.keyCode == KeyboardEvent.DOM_VK_DOWN) { + if (event.altKey) { + // Keep the currently selected result in the list (if any) as a + // secondary "alt" selection and move the selection down within + // the buttons. + this.advanceSelection(true, false, false); + return true; + } + if (numListItems == 0) { + this.advanceSelection(true, true, false); + return true; + } + if ( + this.selectedViewIndex >= 0 && + this.selectedViewIndex < numListItems - 1 + ) { + // Moving down within the list. The autocomplete controller + // should handle this case. A button may be selected, so null it. + this.selectedButton = null; + return false; + } + if (this.selectedViewIndex == numListItems - 1) { + // Moving down from the last item in the list to the buttons. + if (!allowEmptySelection) { + this.selectedViewIndex = -1; + if (this.textbox && typeof textboxUserValue == "string") { + this.textbox.value = textboxUserValue; + } + } + this.selectedButtonIndex = 0; + if (allowEmptySelection) { + // Let the autocomplete controller remove selection in the list + // and revert the typed text in the textbox. + return false; + } + return true; + } + if (this.selectedButton) { + let buttons = this.getSelectableButtons(true); + if (this.selectedButtonIndex == buttons.length - 1) { + // Moving down from the buttons back up to the top of the list. + this.selectedButton = null; + if (allowEmptySelection) { + // Prevent the selection from wrapping around to the top of + // the list by returning true, since the list currently has no + // selection. Nothing should be selected after handling this + // Down key. + return true; + } + return false; + } + // Moving down/right within the buttons. + this.advanceSelection(true, true, false); + return true; + } + return false; + } + + if (event.keyCode == KeyboardEvent.DOM_VK_LEFT) { + if ( + this.selectedButton && + this.selectedButton.engine && + !this.disableOneOffsHorizontalKeyNavigation + ) { + // Moving left within the buttons. + this.advanceSelection(false, true, true); + return true; + } + return false; + } + + if (event.keyCode == KeyboardEvent.DOM_VK_RIGHT) { + if ( + this.selectedButton && + this.selectedButton.engine && + !this.disableOneOffsHorizontalKeyNavigation + ) { + // Moving right within the buttons. + this.advanceSelection(true, true, true); + return true; + } + return false; + } + + return false; + } + + /** + * Determines if the target of the event is a one-off button or + * context menu on a one-off button. + * + * @param {Event} event + * An event, like a click on a one-off button. + * @returns {boolean} True if telemetry was recorded and false if not. + */ + eventTargetIsAOneOff(event) { + if (!event) { + return false; + } + + let target = event.originalTarget; + + if (KeyboardEvent.isInstance(event) && this.selectedButton) { + return true; + } + if ( + MouseEvent.isInstance(event) && + target.classList.contains("searchbar-engine-one-off-item") + ) { + return true; + } + if ( + this.window.XULCommandEvent.isInstance(event) && + target.classList.contains("search-one-offs-context-open-in-new-tab") + ) { + return true; + } + + return false; + } + + // Methods for subclasses to override + + /** + * @returns {boolean} True if the one-offs are connected to a view. + */ + get hasView() { + return !!this.popup; + } + + /** + * @returns {boolean} True if the view is open. + */ + get isViewOpen() { + return this.popup && this.popup.popupOpen; + } + + /** + * @returns {number} The selected index in the view or -1 if no selection. + */ + get selectedViewIndex() { + return this.popup.selectedIndex; + } + + /** + * Sets the selected index in the view. + * + * @param {number} val + * The selected index or -1 if no selection. + */ + set selectedViewIndex(val) { + this.popup.selectedIndex = val; + } + + /** + * Closes the view. + */ + closeView() { + this.popup.hidePopup(); + } + + /** + * Called when a one-off is clicked or the "Search in New Tab" context menu + * item is picked. This is not called for the settings button. + * + * @param {event} event + * The event that triggered the pick. + * @param {nsISearchEngine|SearchEngine} engine + * The engine that was picked. + * @param {boolean} forceNewTab + * True if the search results page should be loaded in a new tab. + */ + handleSearchCommand(event, engine, forceNewTab = false) { + let { where, params } = this._whereToOpen(event, forceNewTab); + this.popup.handleOneOffSearch(event, engine, where, params); + } + + /** + * Sets the tooltip for a one-off button with an engine. This should set + * either the `tooltiptext` attribute or the relevant l10n ID. + * + * @param {element} button + * The one-off button. + */ + setTooltipForEngineButton(button) { + button.setAttribute("tooltiptext", button.engine.name); + } + + // Event handlers below. + + _on_mousedown(event) { + // This is necessary to prevent the input from losing focus and closing the + // popup. Unfortunately it also has the side effect of preventing the + // buttons from receiving the `:active` pseudo-class. + event.preventDefault(); + } + + _on_click(event) { + if (event.button == 2) { + return; // ignore right clicks. + } + + let button = event.originalTarget; + let engine = button.engine; + + if (!engine) { + return; + } + + // Select the clicked button so that consumers can easily tell which + // button was acted on. + this.selectedButton = button; + this.handleSearchCommand(event, engine); + } + + _on_command(event) { + let target = event.target; + + if (target == this.settingsButton) { + this.window.openPreferences("paneSearch"); + + // If the preference tab was already selected, the panel doesn't + // close itself automatically. + this.closeView(); + return; + } + + if (target.classList.contains("searchbar-engine-one-off-add-engine")) { + // On success, hide the panel and tell event listeners to reshow it to + // show the new engine. + lazy.SearchUIUtils.addOpenSearchEngine( + target.getAttribute("uri"), + target.getAttribute("image"), + this.window.gBrowser.selectedBrowser.browsingContext + ) + .then(result => { + if (result) { + this._rebuild(); + } + }) + .catch(console.error); + return; + } + + if (target.classList.contains("search-one-offs-context-open-in-new-tab")) { + // Select the context-clicked button so that consumers can easily + // tell which button was acted on. + this.selectedButton = target.closest("menupopup")._triggerButton; + this.handleSearchCommand(event, this.selectedButton.engine, true); + } + + const isPrivateButton = target.classList.contains( + "search-one-offs-context-set-default-private" + ); + if ( + target.classList.contains("search-one-offs-context-set-default") || + isPrivateButton + ) { + const engineType = isPrivateButton + ? "defaultPrivateEngine" + : "defaultEngine"; + let currentEngine = Services.search[engineType]; + + const isPrivateWin = lazy.PrivateBrowsingUtils.isWindowPrivate( + this.window + ); + let button = target.closest("menupopup")._triggerButton; + // We're about to replace this, so it must be stored now. + let newDefaultEngine = button.engine; + if ( + !this.getAttribute("includecurrentengine") && + isPrivateButton == isPrivateWin + ) { + // Make the target button of the context menu reflect the current + // search engine first. Doing this as opposed to rebuilding all the + // one-off buttons avoids flicker. + let iconURL = + currentEngine.getIconURL() || + "chrome://browser/skin/search-engine-placeholder.png"; + button.setAttribute("image", iconURL); + button.setAttribute("tooltiptext", currentEngine.name); + button.engine = currentEngine; + } + + if (isPrivateButton) { + Services.search.setDefaultPrivate( + newDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT + ); + } else { + Services.search.setDefault( + newDefaultEngine, + Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR_CONTEXT + ); + } + } + } + + _on_contextmenu(event) { + let target = event.originalTarget; + // Prevent the context menu from appearing except on the one off buttons. + if ( + !target.classList.contains("searchbar-engine-one-off-item") || + target.classList.contains("search-setting-button") + ) { + event.preventDefault(); + return; + } + this.contextMenuPopup + .querySelector(".search-one-offs-context-set-default") + .setAttribute( + "disabled", + target.engine == Services.search.defaultEngine.wrappedJSObject + ); + + const privateDefaultItem = this.contextMenuPopup.querySelector( + ".search-one-offs-context-set-default-private" + ); + + if ( + Services.prefs.getBoolPref( + "browser.search.separatePrivateDefault.ui.enabled", + false + ) && + Services.prefs.getBoolPref("browser.search.separatePrivateDefault", false) + ) { + privateDefaultItem.hidden = false; + privateDefaultItem.setAttribute( + "disabled", + target.engine == Services.search.defaultPrivateEngine.wrappedJSObject + ); + } else { + privateDefaultItem.hidden = true; + } + + // When a context menu is opened on a one-off button, this is set to the + // button to be used for the command. + this.contextMenuPopup._triggerButton = target; + this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true); + event.preventDefault(); + } + + _on_input(event) { + // Allow the consumer's input to override its value property with + // a oneOffSearchQuery property. That way if the value is not + // actually what the user typed (e.g., it's autofilled, or it's a + // mozaction URI), the consumer has some way of providing it. + this.query = event.target.oneOffSearchQuery || event.target.value; + } + + _on_popupshowing() { + this._rebuild(); + } + + _on_popuphidden() { + this.selectedButton = null; + } +} diff --git a/browser/components/search/SearchSERPTelemetry.sys.mjs b/browser/components/search/SearchSERPTelemetry.sys.mjs new file mode 100644 index 0000000000..00105241bb --- /dev/null +++ b/browser/components/search/SearchSERPTelemetry.sys.mjs @@ -0,0 +1,2515 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Region: "resource://gre/modules/Region.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "gCryptoHash", () => { + return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); +}); + +// The various histograms and scalars that we report to. +const SEARCH_CONTENT_SCALAR_BASE = "browser.search.content."; +const SEARCH_WITH_ADS_SCALAR_BASE = "browser.search.withads."; +const SEARCH_AD_CLICKS_SCALAR_BASE = "browser.search.adclicks."; +const SEARCH_DATA_TRANSFERRED_SCALAR = "browser.search.data_transferred"; +const SEARCH_TELEMETRY_PRIVATE_BROWSING_KEY_SUFFIX = "pb"; + +// Exported for tests. +export const ADLINK_CHECK_TIMEOUT_MS = 1000; +// Unlike the standard adlink check, the timeout for single page apps is not +// based on a content event within the page, like DOMContentLoaded or load. +// Thus, we aim for a longer timeout to account for when the server might be +// slow to update the content on the page. +export const SPA_ADLINK_CHECK_TIMEOUT_MS = 2500; +export const TELEMETRY_SETTINGS_KEY = "search-telemetry-v2"; +export const TELEMETRY_CATEGORIZATION_KEY = "search-categorization"; +export const TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS = { + // Units are in milliseconds. + base: 3600000, + minAdjust: 60000, + maxAdjust: 600000, + maxTriesPerSession: 2, +}; + +export const SEARCH_TELEMETRY_SHARED = { + PROVIDER_INFO: "SearchTelemetry:ProviderInfo", + LOAD_TIMEOUT: "SearchTelemetry:LoadTimeout", + SPA_LOAD_TIMEOUT: "SearchTelemetry:SPALoadTimeout", +}; + +const impressionIdsWithoutEngagementsSet = new Set(); + +export const CATEGORIZATION_SETTINGS = { + MAX_DOMAINS_TO_CATEGORIZE: 10, + MINIMUM_SCORE: 0, + STARTING_RANK: 2, + IDLE_TIMEOUT_SECONDS: 60 * 60, + WAKE_TIMEOUT_MS: 60 * 60 * 1000, +}; + +ChromeUtils.defineLazyGetter(lazy, "logConsole", () => { + return console.createInstance({ + prefix: "SearchTelemetry", + maxLogLevel: lazy.SearchUtils.loggingEnabled ? "Debug" : "Warn", + }); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serpEventsEnabled", + "browser.search.serpEventTelemetry.enabled", + true +); + +const CATEGORIZATION_PREF = + "browser.search.serpEventTelemetryCategorization.enabled"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serpEventTelemetryCategorization", + CATEGORIZATION_PREF, + false, + (aPreference, previousValue, newValue) => { + if (newValue) { + SearchSERPDomainToCategoriesMap.init(); + SearchSERPCategorizationEventScheduler.init(); + } else { + SearchSERPDomainToCategoriesMap.uninit(); + SearchSERPCategorizationEventScheduler.uninit(); + } + } +); + +export const SearchSERPTelemetryUtils = { + ACTIONS: { + CLICKED: "clicked", + EXPANDED: "expanded", + SUBMITTED: "submitted", + }, + COMPONENTS: { + AD_CAROUSEL: "ad_carousel", + AD_IMAGE_ROW: "ad_image_row", + AD_LINK: "ad_link", + AD_SIDEBAR: "ad_sidebar", + AD_SITELINK: "ad_sitelink", + INCONTENT_SEARCHBOX: "incontent_searchbox", + NON_ADS_LINK: "non_ads_link", + REFINED_SEARCH_BUTTONS: "refined_search_buttons", + SHOPPING_TAB: "shopping_tab", + }, + ABANDONMENTS: { + NAVIGATION: "navigation", + TAB_CLOSE: "tab_close", + WINDOW_CLOSE: "window_close", + }, + INCONTENT_SOURCES: { + OPENED_IN_NEW_TAB: "opened_in_new_tab", + REFINE_ON_SERP: "follow_on_from_refine_on_SERP", + SEARCHBOX: "follow_on_from_refine_on_incontent_search", + }, + CATEGORIZATION: { + INCONCLUSIVE: 0, + }, +}; + +const AD_COMPONENTS = [ + SearchSERPTelemetryUtils.COMPONENTS.AD_CAROUSEL, + SearchSERPTelemetryUtils.COMPONENTS.AD_IMAGE_ROW, + SearchSERPTelemetryUtils.COMPONENTS.AD_LINK, + SearchSERPTelemetryUtils.COMPONENTS.AD_SIDEBAR, + SearchSERPTelemetryUtils.COMPONENTS.AD_SITELINK, +]; + +/** + * TelemetryHandler is the main class handling Search Engine Result Page (SERP) + * telemetry. It primarily deals with tracking of what pages are loaded into tabs. + * + * It handles the *in-content:sap* keys of the SEARCH_COUNTS histogram. + */ +class TelemetryHandler { + // Whether or not this class is initialised. + _initialized = false; + + // An instance of ContentHandler. + _contentHandler; + + // The original provider information, mainly used for tests. + _originalProviderInfo = null; + + // The current search provider info. + _searchProviderInfo = null; + + // An instance of remote settings that is used to access the provider info. + _telemetrySettings; + + // Callback used when syncing telemetry settings. + #telemetrySettingsSync; + + // _browserInfoByURL is a map of tracked search urls to objects containing: + // * {object} info + // the search provider information associated with the url. + // * {WeakMap} browserTelemetryStateMap + // a weak map of browsers that have the url loaded, their ad report state, + // and their impression id. + // * {integer} count + // a manual count of browsers logged. + // We keep a weak map of browsers, in case we miss something on our counts + // and cause a memory leak - worst case our map is slightly bigger than it + // needs to be. + // The manual count is because WeakMap doesn't give us size/length + // information, but we want to know when we can clean up our associated + // entry. + _browserInfoByURL = new Map(); + + // Browser objects mapped to the info in _browserInfoByURL. + #browserToItemMap = new WeakMap(); + + // _browserSourceMap is a map of the latest search source for a particular + // browser - one of the KNOWN_SEARCH_SOURCES in BrowserSearchTelemetry. + _browserSourceMap = new WeakMap(); + + /** + * A WeakMap whose key is a browser with value of a source type found in + * INCONTENT_SOURCES. Kept separate to avoid overlapping with legacy + * search sources. These sources are specific to the content of a search + * provider page rather than something from within the browser itself. + */ + #browserContentSourceMap = new WeakMap(); + + /** + * Sets the source of a SERP visit from something that occured in content + * rather than from the browser. + * + * @param {browser} browser + * The browser object associated with the page that should be a SERP. + * @param {string} source + * The source that started the load. One of + * SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX, + * SearchSERPTelemetryUtils.INCONTENT_SOURCES.OPENED_IN_NEW_TAB or + * SearchSERPTelemetryUtils.INCONTENT_SOURCES.REFINE_ON_SERP. + */ + setBrowserContentSource(browser, source) { + this.#browserContentSourceMap.set(browser, source); + } + + // _browserNewtabSessionMap is a map of the newtab session id for particular + // browsers. + _browserNewtabSessionMap = new WeakMap(); + + constructor() { + this._contentHandler = new ContentHandler({ + browserInfoByURL: this._browserInfoByURL, + findBrowserItemForURL: (...args) => this._findBrowserItemForURL(...args), + checkURLForSerpMatch: (...args) => this._checkURLForSerpMatch(...args), + findItemForBrowser: (...args) => this.findItemForBrowser(...args), + }); + } + + /** + * Initializes the TelemetryHandler and its ContentHandler. It will add + * appropriate listeners to the window so that window opening and closing + * can be tracked. + */ + async init() { + if (this._initialized) { + return; + } + + this._telemetrySettings = lazy.RemoteSettings(TELEMETRY_SETTINGS_KEY); + let rawProviderInfo = []; + try { + rawProviderInfo = await this._telemetrySettings.get(); + } catch (ex) { + lazy.logConsole.error("Could not get settings:", ex); + } + + this.#telemetrySettingsSync = event => this.#onSettingsSync(event); + this._telemetrySettings.on("sync", this.#telemetrySettingsSync); + + // Send the provider info to the child handler. + this._contentHandler.init(rawProviderInfo); + this._originalProviderInfo = rawProviderInfo; + + // Now convert the regexps into + this._setSearchProviderInfo(rawProviderInfo); + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this._registerWindow(win); + } + Services.wm.addListener(this); + + this._initialized = true; + } + + async #onSettingsSync(event) { + let current = event.data?.current; + if (current) { + lazy.logConsole.debug( + "Update provider info due to Remote Settings sync." + ); + this._originalProviderInfo = current; + this._setSearchProviderInfo(current); + Services.ppmm.sharedData.set( + SEARCH_TELEMETRY_SHARED.PROVIDER_INFO, + current + ); + Services.ppmm.sharedData.flush(); + } else { + lazy.logConsole.debug( + "Ignoring Remote Settings sync data due to missing records." + ); + } + Services.obs.notifyObservers(null, "search-telemetry-v2-synced"); + } + + /** + * Uninitializes the TelemetryHandler and its ContentHandler. + */ + uninit() { + if (!this._initialized) { + return; + } + + this._contentHandler.uninit(); + + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this._unregisterWindow(win); + } + Services.wm.removeListener(this); + + try { + this._telemetrySettings.off("sync", this.#telemetrySettingsSync); + } catch (ex) { + lazy.logConsole.error( + "Failed to shutdown SearchSERPTelemetry Remote Settings.", + ex + ); + } + this._telemetrySettings = null; + this.#telemetrySettingsSync = null; + + this._initialized = false; + } + + /** + * Records the search source for particular browsers, in case it needs + * to be associated with a SERP. + * + * @param {browser} browser + * The browser where the search originated. + * @param {string} source + * Where the search originated from. + */ + recordBrowserSource(browser, source) { + this._browserSourceMap.set(browser, source); + } + + /** + * Records the newtab source for particular browsers, in case it needs + * to be associated with a SERP. + * + * @param {browser} browser + * The browser where the search originated. + * @param {string} newtabSessionId + * The sessionId of the newtab session the search originated from. + */ + recordBrowserNewtabSession(browser, newtabSessionId) { + this._browserNewtabSessionMap.set(browser, newtabSessionId); + } + + /** + * Helper function for recording the reason for a Glean abandonment event. + * + * @param {string} impressionId + * The impression id for the abandonment event about to be recorded. + * @param {string} reason + * The reason the SERP is deemed abandoned. + * One of SearchSERPTelemetryUtils.ABANDONMENTS. + */ + recordAbandonmentTelemetry(impressionId, reason) { + impressionIdsWithoutEngagementsSet.delete(impressionId); + + lazy.logConsole.debug( + `Recording an abandonment event for impression id ${impressionId} with reason: ${reason}` + ); + + Glean.serp.abandonment.record({ + impression_id: impressionId, + reason, + }); + } + + /** + * Handles the TabClose event received from the listeners. + * + * @param {object} event + * The event object provided by the listener. + */ + handleEvent(event) { + if (event.type != "TabClose") { + console.error("Received unexpected event type", event.type); + return; + } + + this._browserNewtabSessionMap.delete(event.target.linkedBrowser); + this.stopTrackingBrowser( + event.target.linkedBrowser, + SearchSERPTelemetryUtils.ABANDONMENTS.TAB_CLOSE + ); + } + + /** + * Test-only function, used to override the provider information, so that + * unit tests can set it to easy to test values. + * + * @param {Array} providerInfo + * See {@link https://searchfox.org/mozilla-central/search?q=search-telemetry-schema.json} + * for type information. + */ + overrideSearchTelemetryForTests(providerInfo) { + let info = providerInfo ? providerInfo : this._originalProviderInfo; + this._contentHandler.overrideSearchTelemetryForTests(info); + this._setSearchProviderInfo(info); + } + + /** + * Used to set the local version of the search provider information. + * This automatically maps the regexps to RegExp objects so that + * we don't have to create a new instance each time. + * + * @param {Array} providerInfo + * A raw array of provider information to set. + */ + _setSearchProviderInfo(providerInfo) { + this._searchProviderInfo = providerInfo.map(provider => { + let newProvider = { + ...provider, + searchPageRegexp: new RegExp(provider.searchPageRegexp), + }; + if (provider.extraAdServersRegexps) { + newProvider.extraAdServersRegexps = provider.extraAdServersRegexps.map( + r => new RegExp(r) + ); + } + + newProvider.nonAdsLinkRegexps = provider.nonAdsLinkRegexps?.length + ? provider.nonAdsLinkRegexps.map(r => new RegExp(r)) + : []; + if (provider.shoppingTab?.regexp) { + newProvider.shoppingTab = { + selector: provider.shoppingTab.selector, + regexp: new RegExp(provider.shoppingTab.regexp), + }; + } + return newProvider; + }); + this._contentHandler._searchProviderInfo = this._searchProviderInfo; + } + + reportPageAction(info, browser) { + this._contentHandler._reportPageAction(info, browser); + } + + reportPageWithAds(info, browser) { + this._contentHandler._reportPageWithAds(info, browser); + } + + reportPageWithAdImpressions(info, browser) { + this._contentHandler._reportPageWithAdImpressions(info, browser); + } + + reportPageDomains(info, browser) { + this._contentHandler._reportPageDomains(info, browser); + } + + reportPageImpression(info, browser) { + this._contentHandler._reportPageImpression(info, browser); + } + + /** + * This may start tracking a tab based on the URL. If the URL matches a search + * partner, and it has a code, then we'll start tracking it. This will aid + * determining if it is a page we should be tracking for adverts. + * + * @param {object} browser + * The browser associated with the page. + * @param {string} url + * The url that was loaded in the browser. + * @param {nsIDocShell.LoadCommand} loadType + * The load type associated with the page load. + */ + updateTrackingStatus(browser, url, loadType) { + if ( + !lazy.BrowserSearchTelemetry.shouldRecordSearchCount( + browser.getTabBrowser() + ) + ) { + return; + } + let info = this._checkURLForSerpMatch(url); + if (!info) { + this._browserNewtabSessionMap.delete(browser); + this.stopTrackingBrowser(browser); + return; + } + + let source = "unknown"; + if (loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) { + source = "reload"; + } else if (loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY) { + source = "tabhistory"; + } else if (this._browserSourceMap.has(browser)) { + source = this._browserSourceMap.get(browser); + this._browserSourceMap.delete(browser); + } + + // If it's a SERP but doesn't have a browser source, the source might be + // from something that happened in content. We keep this separate from + // source because legacy telemetry should not change its reporting. + let inContentSource; + if ( + lazy.serpEventsEnabled && + info.hasComponents && + this.#browserContentSourceMap.has(browser) + ) { + inContentSource = this.#browserContentSourceMap.get(browser); + this.#browserContentSourceMap.delete(browser); + } + + let newtabSessionId; + if (this._browserNewtabSessionMap.has(browser)) { + newtabSessionId = this._browserNewtabSessionMap.get(browser); + // We leave the newtabSessionId in the map for this browser + // until we stop loading SERP pages or the tab is closed. + } + + let impressionId; + if (lazy.serpEventsEnabled && info.hasComponents) { + // The UUID generated by Services.uuid contains leading and trailing braces. + // Need to trim them first. + impressionId = Services.uuid.generateUUID().toString().slice(1, -1); + + impressionIdsWithoutEngagementsSet.add(impressionId); + } + + this._reportSerpPage(info, source, url); + + // For single page apps, we store the page by its original URI so the + // network observers can recover the browser in a context when they only + // have access to the originURL. + let urlKey = + info.isSPA && browser.originalURI?.spec ? browser.originalURI.spec : url; + let item = this._browserInfoByURL.get(urlKey); + + let impressionInfo; + if (lazy.serpEventsEnabled && info.hasComponents) { + let partnerCode = ""; + if (info.code != "none" && info.code != null) { + partnerCode = info.code; + } + impressionInfo = { + provider: info.provider, + tagged: info.type.startsWith("tagged"), + partnerCode, + source: inContentSource ?? source, + isShoppingPage: info.isShoppingPage, + isPrivate: lazy.PrivateBrowsingUtils.isBrowserPrivate(browser), + }; + } + + if (item) { + item.browserTelemetryStateMap.set(browser, { + adsReported: false, + adImpressionsReported: false, + impressionId, + urlToComponentMap: null, + impressionInfo, + searchBoxSubmitted: false, + categorizationInfo: null, + adsClicked: 0, + adsVisible: 0, + searchQuery: info.searchQuery, + }); + item.count++; + item.source = source; + item.newtabSessionId = newtabSessionId; + } else { + item = { + browserTelemetryStateMap: new WeakMap().set(browser, { + adsReported: false, + adImpressionsReported: false, + impressionId, + urlToComponentMap: null, + impressionInfo, + searchBoxSubmitted: false, + categorizationInfo: null, + adsClicked: 0, + adsVisible: 0, + searchQuery: info.searchQuery, + }), + info, + count: 1, + source, + newtabSessionId, + majorVersion: parseInt(Services.appinfo.version), + channel: lazy.SearchUtils.MODIFIED_APP_CHANNEL, + region: lazy.Region.home, + isSPA: info.isSPA, + }; + // For single page apps, we store the page by its original URI so that + // network observers can recover the browser in a context when they only + // have the originURL to work with. + this._browserInfoByURL.set(urlKey, item); + } + this.#browserToItemMap.set(browser, item); + } + + /** + * Determines whether or not a browser should be untracked or tracked for + * SERPs who have single page app behaviour. + * + * The over-arching logic: + * 1. Only inspect the browser if the url matches a SERP that is a SPA. + * 2. Recording an engagement if we're tracking the browser and we're going + * to another page. + * 3. Untrack the browser if we're tracking it and switching pages. + * 4. Track the browser if we're now on a default search page. + * + * @param {BrowserElement} browser + * The browser element related to the request. + * @param {string} url + * The url of the request. + * @param {number} loadType + * The loadtype of a the request. + */ + async updateTrackingSinglePageApp(browser, url, loadType) { + let providerInfo = this._getProviderInfoForURL(url); + if (!providerInfo?.isSPA) { + return; + } + + let item = this.findItemForBrowser(browser); + let telemetryState = item?.browserTelemetryStateMap.get(browser); + + let previousSearchTerm = telemetryState?.searchQuery ?? ""; + let searchTerm = this.urlSearchTerms(url, providerInfo); + let searchTermChanged = previousSearchTerm !== searchTerm; + + let isSerp = !!this._checkURLForSerpMatch(url, providerInfo); + let browserIsTracked = !!telemetryState; + let isTabHistory = loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY; + + // Step 2: Maybe record engagement. + if (browserIsTracked && !isTabHistory && (searchTermChanged || !isSerp)) { + // If we've established we've changed to another SERP, the cause could be + // from a submission event inside the content process. The event is + // sent to the parent and stored as `telemetryState.searchBoxSubmitted` + // but if we check now, it may be too early. Instead, we check with the + // content process directly to see if it recorded a submit event. + let actor = browser.browsingContext.currentWindowGlobal.getActor( + "SearchSERPTelemetry" + ); + let didSubmit = await actor.sendQuery("SearchSERPTelemetry:DidSubmit"); + + if (telemetryState && !telemetryState.searchBoxSubmitted && !didSubmit) { + impressionIdsWithoutEngagementsSet.delete(telemetryState.impressionId); + Glean.serp.engagement.record({ + impression_id: telemetryState.impressionId, + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + }); + lazy.logConsole.debug("Counting click:", { + impressionId: telemetryState.impressionId, + type: SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK, + URL: url, + }); + } + } + + // Step 3: Maybe untrack the browser. + if (browserIsTracked && (searchTermChanged || !isSerp)) { + let reason = ""; + // If we have to untrack it, it might be due to the user using the + // back/forward button. + if (isTabHistory) { + reason = SearchSERPTelemetryUtils.ABANDONMENTS.NAVIGATION; + } + let actor = browser.browsingContext.currentWindowGlobal.getActor( + "SearchSERPTelemetry" + ); + actor.sendAsyncMessage("SearchSERPTelemetry:StopTrackingDocument"); + this.stopTrackingBrowser(browser, reason); + browserIsTracked = false; + } + + // Step 4: Maybe track the browser. + if (isSerp && !browserIsTracked) { + this.updateTrackingStatus(browser, url, loadType); + let actor = browser.browsingContext.currentWindowGlobal.getActor( + "SearchSERPTelemetry" + ); + actor.sendAsyncMessage("SearchSERPTelemetry:WaitForSPAPageLoad"); + } + } + + /** + * Stops tracking of a tab, for example the tab has loaded a different URL. + * Also records a Glean abandonment event if appropriate. + * + * @param {object} browser The browser associated with the tab to stop being + * tracked. + * @param {string} abandonmentReason + * An optional parameter that specifies why the browser is deemed abandoned. + * The reason will be recorded as part of Glean abandonment telemetry. + * One of SearchSERPTelemetryUtils.ABANDONMENTS. + */ + stopTrackingBrowser(browser, abandonmentReason) { + for (let [url, item] of this._browserInfoByURL) { + if (item.browserTelemetryStateMap.has(browser)) { + let telemetryState = item.browserTelemetryStateMap.get(browser); + let impressionId = telemetryState.impressionId; + if (impressionIdsWithoutEngagementsSet.has(impressionId)) { + this.recordAbandonmentTelemetry(impressionId, abandonmentReason); + } + + if ( + lazy.serpEventTelemetryCategorization && + telemetryState.categorizationInfo + ) { + SearchSERPCategorizationEventScheduler.sendCallback(browser); + } + + item.browserTelemetryStateMap.delete(browser); + item.count--; + } + + if (!item.count) { + this._browserInfoByURL.delete(url); + } + } + this.#browserToItemMap.delete(browser); + } + + /** + * Calculate how close two urls are in equality. + * + * The scoring system: + * - If the URLs look exactly the same, including the ordering of query + * parameters, the score is Infinity. + * - If the origin is the same, the score is increased by 1. Otherwise the + * score is 0. + * - If the path is the same, the score is increased by 1. + * - For each query parameter, if the key exists the score is increased by 1. + * Likewise if the query parameter values match. + * - If the hash is the same, the score is increased by 1. This includes if + * the hash is missing in both URLs. + * + * @param {URL} url1 + * Url to compare. + * @param {URL} url2 + * Other url to compare. Ordering shouldn't matter. + * @param {object} [matchOptions] + * Options for checking equality. + * @param {boolean} [matchOptions.path] + * Whether the path must match. Default to false. + * @param {boolean} [matchOptions.paramValues] + * Whether the values of the query parameters must match if the query + * parameter key exists in the other. Defaults to false. + * @returns {number} + * A score of how closely the two URLs match. Returns 0 if there is no + * match or the equality check failed for an enabled match option. + */ + compareUrls(url1, url2, matchOptions = {}) { + // In case of an exact match, well, that's an obvious winner. + if (url1.href == url2.href) { + return Infinity; + } + + // Each step we get closer to the two URLs being the same, we increase the + // score. The consumer of this method will use these scores to see which + // of the URLs is the best match. + let score = 0; + if (url1.origin == url2.origin) { + ++score; + if (url1.pathname == url2.pathname) { + ++score; + for (let [key1, value1] of url1.searchParams) { + // Let's not fuss about the ordering of search params, since the + // score effect will solve that. + if (url2.searchParams.has(key1)) { + ++score; + if (url2.searchParams.get(key1) == value1) { + ++score; + } else if (matchOptions.paramValues) { + return 0; + } + } + } + if (url1.hash == url2.hash) { + ++score; + } + } else if (matchOptions.path) { + return 0; + } + } + return score; + } + + /** + * Extracts the search terms from the URL based on the provider info. + * + * @param {string} url + * The URL to inspect. + * @param {object} providerInfo + * The providerInfo associated with the URL. + * @returns {string} + * The search term or if none is found, a blank string. + */ + urlSearchTerms(url, providerInfo) { + if (providerInfo?.queryParamNames?.length) { + let { searchParams } = new URL(url); + for (let queryParamName of providerInfo.queryParamNames) { + let value = searchParams.get(queryParamName); + if (value) { + return value; + } + } + } + return ""; + } + + findItemForBrowser(browser) { + return this.#browserToItemMap.get(browser); + } + + /** + * Parts of the URL, like search params and hashes, may be mutated by scripts + * on a page we're tracking. Since we don't want to keep track of that + * ourselves in order to keep the list of browser objects a weak-referenced + * set, we do optional fuzzy matching of URLs to fetch the most relevant item + * that contains tracking information. + * + * @param {string} url URL to fetch the tracking data for. + * @returns {object} Map containing the following members: + * - {WeakMap} browsers + * Map of browser elements that belong to `url` and their ad report state. + * - {object} info + * Info dictionary as returned by `_checkURLForSerpMatch`. + * - {number} count + * The number of browser element we can most accurately tell we're + * tracking, since they're inside a WeakMap. + */ + _findBrowserItemForURL(url) { + try { + url = new URL(url); + } catch (ex) { + return null; + } + + let item; + let currentBestMatch = 0; + for (let [trackingURL, candidateItem] of this._browserInfoByURL) { + if (currentBestMatch === Infinity) { + break; + } + try { + // Make sure to cache the parsed URL object, since there's no reason to + // do it twice. + trackingURL = + candidateItem._trackingURL || + (candidateItem._trackingURL = new URL(trackingURL)); + } catch (ex) { + continue; + } + let score = this.compareUrls(url, trackingURL); + if (score > currentBestMatch) { + item = candidateItem; + currentBestMatch = score; + } + } + + return item; + } + + // nsIWindowMediatorListener + + /** + * This is called when a new window is opened, and handles registration of + * that window if it is a browser window. + * + * @param {nsIAppWindow} appWin The xul window that was opened. + */ + onOpenWindow(appWin) { + let win = appWin.docShell.domWindow; + win.addEventListener( + "load", + () => { + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + + this._registerWindow(win); + }, + { once: true } + ); + } + + /** + * Listener that is called when a window is closed, and handles deregistration of + * that window if it is a browser window. + * + * @param {nsIAppWindow} appWin The xul window that was closed. + */ + onCloseWindow(appWin) { + let win = appWin.docShell.domWindow; + + if ( + win.document.documentElement.getAttribute("windowtype") != + "navigator:browser" + ) { + return; + } + + this._unregisterWindow(win); + } + + /** + * Adds event listeners for the window and registers it with the content handler. + * + * @param {object} win The window to register. + */ + _registerWindow(win) { + win.gBrowser.tabContainer.addEventListener("TabClose", this); + } + + /** + * Removes event listeners for the window and unregisters it with the content + * handler. + * + * @param {object} win The window to unregister. + */ + _unregisterWindow(win) { + for (let tab of win.gBrowser.tabs) { + this.stopTrackingBrowser( + tab.linkedBrowser, + SearchSERPTelemetryUtils.ABANDONMENTS.WINDOW_CLOSE + ); + } + + win.gBrowser.tabContainer.removeEventListener("TabClose", this); + } + + /** + * Searches for provider information for a given url. + * + * @param {string} url The url to match for a provider. + * @returns {Array | null} Returns an array of provider name and the provider information. + */ + _getProviderInfoForURL(url) { + return this._searchProviderInfo.find(info => + info.searchPageRegexp.test(url) + ); + } + + /** + * Checks to see if a url is a search partner location, and determines the + * provider and codes used. + * + * @param {string} url The url to match. + * @returns {null|object} Returns null if there is no match found. Otherwise, + * returns an object of strings for provider, code and type. + */ + _checkURLForSerpMatch(url) { + let searchProviderInfo = this._getProviderInfoForURL(url); + if (!searchProviderInfo) { + return null; + } + + let queries = new URLSearchParams(url.split("#")[0].split("?")[1]); + + let isSPA = !!searchProviderInfo.isSPA; + if (isSPA) { + // A URL may have a specific query parameter denoting a search page. + // If the key was expected but doesn't currently exist, it could be due to + // the initial url containing it until after a page load. + // In that case, ignore this check since most SERPs missing the query + // param will go to the default search page. + let { key, value } = searchProviderInfo.defaultPageQueryParam; + if (key && queries.has(key) && queries.get(key) != value) { + return null; + } + } + + // Some URLs can match provider info but also be the provider's homepage + // instead of a SERP. + // e.g. https://example.com/ vs. https://example.com/?foo=bar + // Look for the presence of the query parameter that contains a search term. + let hasQuery = false; + let searchQuery = ""; + for (let queryParamName of searchProviderInfo.queryParamNames) { + searchQuery = queries.get(queryParamName); + if (searchQuery) { + hasQuery = true; + break; + } + } + if (!hasQuery) { + return null; + } + // Default to organic to simplify things. + // We override type in the sap cases. + let type = "organic"; + let code; + if (searchProviderInfo.codeParamName) { + code = queries.get(searchProviderInfo.codeParamName); + if (code) { + // The code is only included if it matches one of the specific ones. + if (searchProviderInfo.taggedCodes.includes(code)) { + type = "tagged"; + if ( + searchProviderInfo.followOnParamNames && + searchProviderInfo.followOnParamNames.some(p => queries.has(p)) + ) { + type += "-follow-on"; + } + } else if (searchProviderInfo.organicCodes.includes(code)) { + type = "organic"; + } else if (searchProviderInfo.expectedOrganicCodes?.includes(code)) { + code = "none"; + } else { + code = "other"; + } + } else if (searchProviderInfo.followOnCookies) { + // Especially Bing requires lots of extra work related to cookies. + for (let followOnCookie of searchProviderInfo.followOnCookies) { + if (followOnCookie.extraCodeParamName) { + let eCode = queries.get(followOnCookie.extraCodeParamName); + if ( + !eCode || + !followOnCookie.extraCodePrefixes.some(p => eCode.startsWith(p)) + ) { + continue; + } + } + + // If this cookie is present, it's probably an SAP follow-on. + // This might be an organic follow-on in the same session, but there + // is no way to tell the difference. + for (let cookie of Services.cookies.getCookiesFromHost( + followOnCookie.host, + {} + )) { + if (cookie.name != followOnCookie.name) { + continue; + } + + let [cookieParam, cookieValue] = cookie.value + .split("=") + .map(p => p.trim()); + if ( + cookieParam == followOnCookie.codeParamName && + searchProviderInfo.taggedCodes.includes(cookieValue) + ) { + type = "tagged-follow-on"; + code = cookieValue; + break; + } + } + } + } + } + let isShoppingPage = false; + let hasComponents = false; + if (lazy.serpEventsEnabled) { + if (searchProviderInfo.shoppingTab?.regexp) { + isShoppingPage = searchProviderInfo.shoppingTab.regexp.test(url); + } + if (searchProviderInfo.components?.length) { + hasComponents = true; + } + } + return { + provider: searchProviderInfo.telemetryId, + type, + code, + isShoppingPage, + hasComponents, + searchQuery, + isSPA, + }; + } + + /** + * Logs telemetry for a search provider visit. + * + * @param {object} info The search provider information. + * @param {string} info.provider The name of the provider. + * @param {string} info.type The type of search. + * @param {string} [info.code] The code for the provider. + * @param {string} source Where the search originated from. + * @param {string} url The url that was matched (for debug logging only). + */ + _reportSerpPage(info, source, url) { + let payload = `${info.provider}:${info.type}:${info.code || "none"}`; + Services.telemetry.keyedScalarAdd( + SEARCH_CONTENT_SCALAR_BASE + source, + payload, + 1 + ); + lazy.logConsole.debug("Impression:", payload, url); + } +} + +/** + * ContentHandler deals with handling telemetry of the content within a tab - + * when ads detected and when they are selected. + */ +class ContentHandler { + /** + * Constructor. + * + * @param {object} options + * The options for the handler. + * @param {Map} options.browserInfoByURL + * The map of urls from TelemetryHandler. + * @param {Function} options.getProviderInfoForURL + * A function that obtains the provider information for a url. + */ + constructor(options) { + this._browserInfoByURL = options.browserInfoByURL; + this._findBrowserItemForURL = options.findBrowserItemForURL; + this._checkURLForSerpMatch = options.checkURLForSerpMatch; + this._findItemForBrowser = options.findItemForBrowser; + } + + /** + * Initializes the content handler. This will also set up the shared data that is + * shared with the SearchTelemetryChild actor. + * + * @param {Array} providerInfo + * The provider information for the search telemetry to record. + */ + init(providerInfo) { + Services.ppmm.sharedData.set( + SEARCH_TELEMETRY_SHARED.PROVIDER_INFO, + providerInfo + ); + Services.ppmm.sharedData.set( + SEARCH_TELEMETRY_SHARED.LOAD_TIMEOUT, + ADLINK_CHECK_TIMEOUT_MS + ); + Services.ppmm.sharedData.set( + SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT, + SPA_ADLINK_CHECK_TIMEOUT_MS + ); + + Services.obs.addObserver(this, "http-on-examine-response"); + Services.obs.addObserver(this, "http-on-examine-cached-response"); + Services.obs.addObserver(this, "http-on-stop-request"); + } + + /** + * Uninitializes the content handler. + */ + uninit() { + Services.obs.removeObserver(this, "http-on-examine-response"); + Services.obs.removeObserver(this, "http-on-examine-cached-response"); + Services.obs.removeObserver(this, "http-on-stop-request"); + } + + /** + * Test-only function to override the search provider information for use + * with tests. Passes it to the SearchTelemetryChild actor. + * + * @param {object} providerInfo @see SEARCH_PROVIDER_INFO for type information. + */ + overrideSearchTelemetryForTests(providerInfo) { + Services.ppmm.sharedData.set("SearchTelemetry:ProviderInfo", providerInfo); + } + + /** + * Reports bandwidth used by the given channel if it is used by search requests. + * + * @param {object} aChannel The channel that generated the activity. + */ + _reportChannelBandwidth(aChannel) { + if (!(aChannel instanceof Ci.nsIChannel)) { + return; + } + let wrappedChannel = ChannelWrapper.get(aChannel); + + let getTopURL = channel => { + // top-level document + if ( + channel.loadInfo && + channel.loadInfo.externalContentPolicyType == + Ci.nsIContentPolicy.TYPE_DOCUMENT + ) { + return channel.finalURL; + } + + // iframe + let frameAncestors; + try { + frameAncestors = channel.frameAncestors; + } catch (e) { + frameAncestors = null; + } + if (frameAncestors) { + let ancestor = frameAncestors.find(obj => obj.frameId == 0); + if (ancestor) { + return ancestor.url; + } + } + + // top-level resource + if (channel.loadInfo && channel.loadInfo.loadingPrincipal) { + return channel.loadInfo.loadingPrincipal.spec; + } + + return null; + }; + + let topUrl = getTopURL(wrappedChannel); + if (!topUrl) { + return; + } + + let info = this._checkURLForSerpMatch(topUrl); + if (!info) { + return; + } + + let bytesTransferred = + wrappedChannel.requestSize + wrappedChannel.responseSize; + let { provider } = info; + + let isPrivate = + wrappedChannel.loadInfo && + wrappedChannel.loadInfo.originAttributes.privateBrowsingId > 0; + if (isPrivate) { + provider += `-${SEARCH_TELEMETRY_PRIVATE_BROWSING_KEY_SUFFIX}`; + } + + Services.telemetry.keyedScalarAdd( + SEARCH_DATA_TRANSFERRED_SCALAR, + provider, + bytesTransferred + ); + } + + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case "http-on-stop-request": + this._reportChannelBandwidth(aSubject); + break; + case "http-on-examine-response": + case "http-on-examine-cached-response": + this.observeActivity(aSubject); + break; + } + } + + /** + * Listener that observes network activity, so that we can determine if a link + * from a search provider page was followed, and if then if that link was an + * ad click or not. + * + * @param {nsIChannel} channel The channel that generated the activity. + */ + observeActivity(channel) { + if (!(channel instanceof Ci.nsIChannel)) { + return; + } + + let wrappedChannel = ChannelWrapper.get(channel); + // The channel we're observing might be a redirect of a channel we've + // observed before. + if (wrappedChannel._adClickRecorded) { + lazy.logConsole.debug("Ad click already recorded"); + return; + } + + Services.tm.dispatchToMainThread(() => { + // We suspect that No Content (204) responses are used to transfer or + // update beacons. They used to lead to double-counting ad-clicks, so let's + // ignore them. + if (wrappedChannel.statusCode == 204) { + lazy.logConsole.debug("Ignoring activity from ambiguous responses"); + return; + } + + // The wrapper is consistent across redirects, so we can use it to track state. + let originURL = wrappedChannel.originURI && wrappedChannel.originURI.spec; + let item = this._findBrowserItemForURL(originURL); + if (!originURL || !item) { + return; + } + + let url = wrappedChannel.finalURL; + + let providerInfo = item.info.provider; + let info = this._searchProviderInfo.find(provider => { + return provider.telemetryId == providerInfo; + }); + + // If an error occurs with Glean SERP telemetry logic, avoid + // disrupting legacy telemetry. + try { + this.#maybeRecordSERPTelemetry(wrappedChannel, item, info); + } catch (ex) { + lazy.logConsole.error(ex); + } + + if (!info.extraAdServersRegexps?.some(regex => regex.test(url))) { + return; + } + + try { + Services.telemetry.keyedScalarAdd( + SEARCH_AD_CLICKS_SCALAR_BASE + item.source, + `${info.telemetryId}:${item.info.type}`, + 1 + ); + wrappedChannel._adClickRecorded = true; + if (item.newtabSessionId) { + Glean.newtabSearchAd.click.record({ + newtab_visit_id: item.newtabSessionId, + search_access_point: item.source, + is_follow_on: item.info.type.endsWith("follow-on"), + is_tagged: item.info.type.startsWith("tagged"), + telemetry_id: item.info.provider, + }); + } + + lazy.logConsole.debug("Counting ad click in page for:", { + source: item.source, + originURL, + URL: url, + }); + } catch (e) { + console.error(e); + } + }); + } + + /** + * Checks if a request should record an ad click if it can be traced to a + * browser containing an observed SERP. + * + * @param {ChannelWrapper} wrappedChannel + * The wrapped channel. + * @param {object} item + * The browser item associated with the origin URL of the request. + * @param {object} info + * The search provider info associated with the item. + */ + #maybeRecordSERPTelemetry(wrappedChannel, item, info) { + if (!lazy.serpEventsEnabled) { + return; + } + + if (wrappedChannel._recordedClick) { + lazy.logConsole.debug("Click already recorded."); + return; + } + + let originURL = wrappedChannel.originURI?.spec; + let url = wrappedChannel.finalURL; + // Some channels re-direct by loading pages that return 200. The result + // is the channel will have an originURL that changes from the SERP to + // either a nonAdsRegexp or an extraAdServersRegexps. This is typical + // for loading a page in a new tab. The channel will have changed so any + // properties attached to them to record state (e.g. _recordedClick) + // won't be present. + if ( + info.nonAdsLinkRegexps.some(r => r.test(originURL)) || + info.extraAdServersRegexps.some(r => r.test(originURL)) + ) { + return; + } + + // A click event is recorded if a user loads a resource from an + // originURL that is a SERP. + // + // Typically, we only want top level loads containing documents to avoid + // recording any event on an in-page resource a SERP might load + // (e.g. CSS files). + // + // The exception to this is if a subframe loads a resource that matches + // a non ad link. Some SERPs encode non ad search results with a URL + // that gets loaded into an iframe, which then tells the container of + // the iframe to change the location of the page. + if ( + wrappedChannel.channel.isDocument && + (wrappedChannel.channel.loadInfo.isTopLevelLoad || + info.nonAdsLinkRegexps.some(r => r.test(url))) + ) { + let browser = wrappedChannel.browserElement; + + // If the load is from history, don't record an event. + if ( + browser?.browsingContext.webProgress?.loadType & + Ci.nsIDocShell.LOAD_CMD_HISTORY + ) { + lazy.logConsole.debug("Ignoring load from history"); + return; + } + + // Step 1: Check if the browser associated with the request was a + // tracked SERP. + let start = Cu.now(); + let telemetryState; + let isFromNewtab = false; + if (item.browserTelemetryStateMap.has(browser)) { + // If the map contains the browser, then it means that the request is + // the SERP is going from one page to another. We know this because + // previous conditions prevent non-top level loads from occuring here. + telemetryState = item.browserTelemetryStateMap.get(browser); + } else if (browser) { + // Alternatively, it could be the case that the request is occuring in + // a new tab but was triggered by one of the browsers in the state map. + // If only one browser exists in the state map, it must be that one. + if (item.count === 1) { + let sourceBrowsers = ChromeUtils.nondeterministicGetWeakMapKeys( + item.browserTelemetryStateMap + ); + if (sourceBrowsers?.length) { + telemetryState = item.browserTelemetryStateMap.get( + sourceBrowsers[0] + ); + } + } else if (item.count > 1) { + // If the count is more than 1, then multiple open SERPs contain the + // same search term, so try to find the specific browser that opened + // the request. + let tabBrowser = browser.getTabBrowser(); + let tab = tabBrowser.getTabForBrowser(browser).openerTab; + // A tab will not always have an openerTab, as first tabs in new + // windows don't have an openerTab. + // Bug 1867582: We should also handle the case where multiple tabs + // contain the same search term. + if (tab) { + telemetryState = item.browserTelemetryStateMap.get( + tab.linkedBrowser + ); + } + } + if (telemetryState) { + isFromNewtab = true; + } + } + + // Step 2: If we have telemetryState, the browser object must be + // associated with another browser that is tracked. Try to find the + // component type on the SERP responsible for the request. + // Exceptions: + // - If a searchbox was used to initiate the load, don't record another + // engagement because the event was logged elsewhere. + // - If the ad impression hasn't been recorded yet, we have no way of + // knowing precisely what kind of component was selected. + let isSerp = false; + if ( + telemetryState && + telemetryState.adImpressionsReported && + !telemetryState.searchBoxSubmitted + ) { + if (info.searchPageRegexp?.test(originURL)) { + isSerp = true; + } + + let startFindComponent = Cu.now(); + let parsedUrl = new URL(url); + // Determine the component type of the link. + let type; + for (let [ + storedUrl, + componentType, + ] of telemetryState.urlToComponentMap.entries()) { + // The URL we're navigating to may have more query parameters if + // the provider adds query parameters when the user clicks on a link. + // On the other hand, the URL we are navigating to may have have + // fewer query parameters because of query param stripping. + // Thus, if a query parameter is missing, a match can still be made + // provided keys that exist in both URLs contain equal values. + let score = SearchSERPTelemetry.compareUrls(storedUrl, parsedUrl, { + paramValues: true, + path: true, + }); + if (score) { + type = componentType; + break; + } + } + ChromeUtils.addProfilerMarker( + "SearchSERPTelemetry._observeActivity", + startFindComponent, + "Find component for URL" + ); + + // Default value for URLs that don't match any components categorized + // on the page. + if (!type) { + type = SearchSERPTelemetryUtils.COMPONENTS.NON_ADS_LINK; + } + + if ( + type == SearchSERPTelemetryUtils.COMPONENTS.REFINED_SEARCH_BUTTONS + ) { + SearchSERPTelemetry.setBrowserContentSource( + browser, + SearchSERPTelemetryUtils.INCONTENT_SOURCES.REFINE_ON_SERP + ); + } else if (isSerp && isFromNewtab) { + SearchSERPTelemetry.setBrowserContentSource( + browser, + SearchSERPTelemetryUtils.INCONTENT_SOURCES.OPENED_IN_NEW_TAB + ); + } + + // Step 3: Record the engagement. + impressionIdsWithoutEngagementsSet.delete(telemetryState.impressionId); + if (AD_COMPONENTS.includes(type)) { + telemetryState.adsClicked += 1; + } + Glean.serp.engagement.record({ + impression_id: telemetryState.impressionId, + action: SearchSERPTelemetryUtils.ACTIONS.CLICKED, + target: type, + }); + lazy.logConsole.debug("Counting click:", { + impressionId: telemetryState.impressionId, + type, + URL: url, + }); + // Prevent re-directed channels from being examined more than once. + wrappedChannel._recordedClick = true; + } + ChromeUtils.addProfilerMarker( + "SearchSERPTelemetry._observeActivity", + start, + "Maybe record user engagement." + ); + } + } + + /** + * Logs telemetry for a page with adverts, if it is one of the partner search + * provider pages that we're tracking. + * + * @param {object} info + * The search provider information for the page. + * @param {boolean} info.hasAds + * Whether or not the page has adverts. + * @param {string} info.url + * The url of the page. + * @param {object} browser + * The browser associated with the page. + */ + _reportPageWithAds(info, browser) { + let item = this._findItemForBrowser(browser); + if (!item) { + lazy.logConsole.warn( + "Expected to report URI for", + info.url, + "with ads but couldn't find the information" + ); + return; + } + + let telemetryState = item.browserTelemetryStateMap.get(browser); + if (telemetryState.adsReported) { + lazy.logConsole.debug( + "Ad was previously reported for browser with URI", + info.url + ); + return; + } + + lazy.logConsole.debug( + "Counting ads in page for", + item.info.provider, + item.info.type, + item.source, + info.url + ); + Services.telemetry.keyedScalarAdd( + SEARCH_WITH_ADS_SCALAR_BASE + item.source, + `${item.info.provider}:${item.info.type}`, + 1 + ); + Services.obs.notifyObservers(null, "reported-page-with-ads"); + + telemetryState.adsReported = true; + + if (item.newtabSessionId) { + Glean.newtabSearchAd.impression.record({ + newtab_visit_id: item.newtabSessionId, + search_access_point: item.source, + is_follow_on: item.info.type.endsWith("follow-on"), + is_tagged: item.info.type.startsWith("tagged"), + telemetry_id: item.info.provider, + }); + } + } + + /** + * Logs ad impression telemetry for a page with adverts, if it is + * one of the partner search provider pages that we're tracking. + * + * @param {object} info + * The search provider information for the page. + * @param {string} info.url + * The url of the page. + * @param {Map<string, object>} info.adImpressions + * A map of ad impressions found for the page, where the key + * is the type of ad component and the value is an object + * containing the number of ads that were loaded, visible, + * and hidden. + * @param {Map<string, string>} info.hrefToComponentMap + * A map of hrefs to their component type. Contains both ads + * and non-ads. + * @param {object} browser + * The browser associated with the page. + */ + _reportPageWithAdImpressions(info, browser) { + let item = this._findItemForBrowser(browser); + if (!item) { + return; + } + let telemetryState = item.browserTelemetryStateMap.get(browser); + if ( + lazy.serpEventsEnabled && + info.adImpressions && + telemetryState && + !telemetryState.adImpressionsReported + ) { + for (let [componentType, data] of info.adImpressions.entries()) { + telemetryState.adsVisible += data.adsVisible; + + lazy.logConsole.debug("Counting ad:", { type: componentType, ...data }); + Glean.serp.adImpression.record({ + impression_id: telemetryState.impressionId, + component: componentType, + ads_loaded: data.adsLoaded, + ads_visible: data.adsVisible, + ads_hidden: data.adsHidden, + }); + } + // Convert hrefToComponentMap to a urlToComponentMap in order to cache + // the query parameters of the href. + let urlToComponentMap = new Map(); + for (let [href, adType] of info.hrefToComponentMap) { + urlToComponentMap.set(new URL(href), adType); + } + telemetryState.urlToComponentMap = urlToComponentMap; + telemetryState.adImpressionsReported = true; + Services.obs.notifyObservers(null, "reported-page-with-ad-impressions"); + } + } + + /** + * Records a page action from a SERP page. Normally, actions are tracked in + * parent process by observing network events but some actions are not + * possible to detect outside of subscribing to the child process. + * + * @param {object} info + * The search provider infomation for the page. + * @param {string} info.type + * The component type that was clicked on. + * @param {string} info.action + * The action taken on the page. + * @param {object} browser + * The browser associated with the page. + */ + _reportPageAction(info, browser) { + let item = this._findItemForBrowser(browser); + if (!item) { + return; + } + let telemetryState = item.browserTelemetryStateMap.get(browser); + let impressionId = telemetryState?.impressionId; + if (info.type && impressionId) { + lazy.logConsole.debug(`Recorded page action:`, { + impressionId: telemetryState.impressionId, + type: info.type, + action: info.action, + }); + Glean.serp.engagement.record({ + impression_id: impressionId, + action: info.action, + target: info.type, + }); + impressionIdsWithoutEngagementsSet.delete(impressionId); + // In-content searches are not be categorized with a type, so they will + // not be picked up in the network processes. + if ( + info.type == SearchSERPTelemetryUtils.COMPONENTS.INCONTENT_SEARCHBOX && + info.action == SearchSERPTelemetryUtils.ACTIONS.SUBMITTED + ) { + telemetryState.searchBoxSubmitted = true; + SearchSERPTelemetry.setBrowserContentSource( + browser, + SearchSERPTelemetryUtils.INCONTENT_SOURCES.SEARCHBOX + ); + } + } else { + lazy.logConsole.warn( + "Expected to report a", + info.action, + "engagement for", + info.url, + "but couldn't find an impression id." + ); + } + } + + _reportPageImpression(info, browser) { + let item = this._findItemForBrowser(browser); + let telemetryState = item.browserTelemetryStateMap.get(browser); + if (!telemetryState?.impressionInfo) { + lazy.logConsole.debug( + "Could not find telemetry state or impression info." + ); + return; + } + let impressionId = telemetryState.impressionId; + if (impressionId) { + let impressionInfo = telemetryState.impressionInfo; + Glean.serp.impression.record({ + impression_id: impressionId, + provider: impressionInfo.provider, + tagged: impressionInfo.tagged, + partner_code: impressionInfo.partnerCode, + source: impressionInfo.source, + shopping_tab_displayed: info.shoppingTabDisplayed, + is_shopping_page: impressionInfo.isShoppingPage, + is_private: impressionInfo.isPrivate, + }); + lazy.logConsole.debug(`Reported Impression:`, { + impressionId, + ...impressionInfo, + shoppingTabDisplayed: info.shoppingTabDisplayed, + }); + Services.obs.notifyObservers(null, "reported-page-with-impression"); + } else { + lazy.logConsole.debug("Could not find an impression id."); + } + } + + /** + * Initiates the categorization and reporting of domains extracted from + * SERPs. + * + * @param {object} info + * The search provider infomation for the page. + * @param {Set} info.nonAdDomains + The non-ad domains extracted from the page. + * @param {Set} info.adDomains + The ad domains extracted from the page. + * @param {object} browser + * The browser associated with the page. + */ + _reportPageDomains(info, browser) { + let item = this._findItemForBrowser(browser); + let telemetryState = item.browserTelemetryStateMap.get(browser); + if (lazy.serpEventTelemetryCategorization && telemetryState) { + let result = SearchSERPCategorization.maybeCategorizeSERP( + info.nonAdDomains, + info.adDomains, + item.info.provider + ); + if (result) { + telemetryState.categorizationInfo = result; + let callback = () => { + let impressionInfo = telemetryState.impressionInfo; + SERPCategorizationRecorder.recordCategorizationTelemetry({ + ...telemetryState.categorizationInfo, + app_version: item.majorVersion, + channel: item.channel, + region: item.region, + partner_code: impressionInfo.partnerCode, + provider: impressionInfo.provider, + tagged: impressionInfo.tagged, + num_ads_clicked: telemetryState.adsClicked, + num_ads_visible: telemetryState.adsVisible, + }); + }; + SearchSERPCategorizationEventScheduler.addCallback(browser, callback); + } + } + Services.obs.notifyObservers( + null, + "reported-page-with-categorized-domains" + ); + } +} + +/** + * @typedef {object} CategorizationResult + * @property {string} organic_category + * The category for the organic result. + * @property {number} organic_num_domains + * The number of domains examined to determine the organic category result. + * @property {number} organic_num_inconclusive + * The number of inconclusive domains when determining the organic result. + * @property {number} organic_num_unknown + * The number of unknown domains when determining the organic result. + * @property {string} sponsored_category + * The category for the organic result. + * @property {number} sponsored_num_domains + * The number of domains examined to determine the sponsored category. + * @property {number} sponsored_num_inconclusive + * The number of inconclusive domains when determining the sponsored category. + * @property {number} sponsored_num_unknown + * The category for the sponsored result. + * @property {string} mappings_version + * The category mapping version used to determine the categories. + */ + +/** + * @typedef {object} CategorizationExtraParams + * @property {number} num_ads_clicked + * The total number of ads clicked on a SERP. + * @property {number} num_ads_visible + * The total number of ads visible to the user when categorization occured. + */ + +/* eslint-disable jsdoc/valid-types */ +/** + * @typedef {CategorizationResult & CategorizationExtraParams} RecordCategorizationParameters + */ +/* eslint-enable jsdoc/valid-types */ + +/** + * Categorizes SERPs. + */ +class SERPCategorizer { + /** + * Categorizes domains extracted from SERPs. Note that we don't process + * domains if the domain-to-categories map is empty (if the client couldn't + * download Remote Settings attachments, for example). + * + * @param {Set} nonAdDomains + * Domains from organic results extracted from the page. + * @param {Set} adDomains + * Domains from ad results extracted from the page. + * @param {string} provider + * The provider associated with the page. + * @returns {CategorizationResult | null} + * The final categorization result. Returns null if the map was empty. + */ + maybeCategorizeSERP(nonAdDomains, adDomains, provider) { + // Per DS, if the map was empty (e.g. because of a technical issue + // downloading the data), we shouldn't report telemetry. + // Thus, there is no point attempting to categorize the SERP. + if (SearchSERPDomainToCategoriesMap.empty) { + return null; + } + let resultsToReport = {}; + + let processedDomains = this.processDomains(nonAdDomains, provider); + let results = this.applyCategorizationLogic(processedDomains); + resultsToReport.organic_category = results.category; + resultsToReport.organic_num_domains = results.num_domains; + resultsToReport.organic_num_unknown = results.num_unknown; + resultsToReport.organic_num_inconclusive = results.num_inconclusive; + + processedDomains = this.processDomains(adDomains, provider); + results = this.applyCategorizationLogic(processedDomains); + resultsToReport.sponsored_category = results.category; + resultsToReport.sponsored_num_domains = results.num_domains; + resultsToReport.sponsored_num_unknown = results.num_unknown; + resultsToReport.sponsored_num_inconclusive = results.num_inconclusive; + + resultsToReport.mappings_version = SearchSERPDomainToCategoriesMap.version; + + return resultsToReport; + } + + /** + * Applies the logic for reducing extracted domains to a single category for + * the SERP. + * + * @param {Set} domains + * The domains extracted from the page. + * @returns {object} resultsToReport + * The final categorization results. Keys are: "category", "num_domains", + * "num_unknown" and "num_inconclusive". + */ + applyCategorizationLogic(domains) { + let domainInfo = {}; + let domainsCount = 0; + let unknownsCount = 0; + let inconclusivesCount = 0; + + // Per a request from Data Science, we need to limit the number of domains + // categorized to 10 non-ad domains and 10 ad domains. + domains = new Set( + [...domains].slice(0, CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE) + ); + + for (let domain of domains) { + domainsCount++; + + let categoryCandidates = SearchSERPDomainToCategoriesMap.get(domain); + + if (!categoryCandidates.length) { + unknownsCount++; + continue; + } + + // Inconclusive domains do not have more than one category candidate. + if ( + categoryCandidates[0].category == + SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE + ) { + inconclusivesCount++; + continue; + } + + domainInfo[domain] = categoryCandidates; + } + + let finalCategory; + let topCategories = []; + // Determine if all domains were unknown or inconclusive. + if (unknownsCount + inconclusivesCount == domainsCount) { + finalCategory = SearchSERPTelemetryUtils.CATEGORIZATION.INCONCLUSIVE; + } else { + let maxScore = CATEGORIZATION_SETTINGS.MINIMUM_SCORE; + let rank = CATEGORIZATION_SETTINGS.STARTING_RANK; + for (let categoryCandidates of Object.values(domainInfo)) { + for (let { category, score } of categoryCandidates) { + let adjustedScore = score / Math.log2(rank); + if (adjustedScore > maxScore) { + maxScore = adjustedScore; + topCategories = [category]; + } else if (adjustedScore == maxScore) { + topCategories.push(Number(category)); + } + rank++; + } + } + finalCategory = + topCategories.length > 1 + ? this.#chooseRandomlyFrom(topCategories) + : topCategories[0]; + } + + return { + category: finalCategory, + num_domains: domainsCount, + num_unknown: unknownsCount, + num_inconclusive: inconclusivesCount, + }; + } + + /** + * Processes raw domains extracted from the SERP into their final form before + * categorization. + * + * @param {Set} domains + * The domains extracted from the page. + * @param {string} provider + * The provider associated with the page. + * @returns {Set} processedDomains + * The final set of processed domains for a page. + */ + processDomains(domains, provider) { + let processedDomains = new Set(); + + for (let domain of domains) { + // Don't include domains associated with the search provider. + if ( + domain.startsWith(`${provider}.`) || + domain.includes(`.${provider}.`) + ) { + continue; + } + let domainWithoutSubdomains = this.#stripDomainOfSubdomains(domain); + // We may have come across the same domain twice, once with www. prefixed + // and another time without. + if ( + domainWithoutSubdomains && + !processedDomains.has(domainWithoutSubdomains) + ) { + processedDomains.add(domainWithoutSubdomains); + } + } + + return processedDomains; + } + + /** + * Helper to strip domains of any subdomains. + * + * @param {string} domain + * The domain to strip of any subdomains. + * @returns {object} browser + * The given domain with any subdomains removed. + */ + #stripDomainOfSubdomains(domain) { + let tld; + // Can throw an exception if the input has too few domain levels. + try { + tld = Services.eTLD.getKnownPublicSuffixFromHost(domain); + } catch (ex) { + return ""; + } + + let domainWithoutTLD = domain.substring(0, domain.length - tld.length); + let secondLevelDomain = domainWithoutTLD.split(".").at(-2); + + return secondLevelDomain ? `${secondLevelDomain}.${tld}` : ""; + } + + #chooseRandomlyFrom(categories) { + let randIdx = Math.floor(Math.random() * categories.length); + return categories[randIdx]; + } +} + +/** + * Contains outstanding categorizations of browser objects that have yet to be + * scheduled to be reported into a Glean event. + * They are kept here until one of the conditions are met: + * 1. The browser that was tracked is no longer being tracked. + * 2. A user has been idle for IDLE_TIMEOUT_SECONDS + * 3. The user has awoken their computer and the time elapsed from the last + * categorization event exceeds WAKE_TIMEOUT_MS. + */ +class CategorizationEventScheduler { + /** + * A WeakMap containing browser objects mapped to a callback. + * + * @type {WeakMap | null} + */ + #browserToCallbackMap = null; + + /** + * An instance of user idle service. Cached for testing purposes. + * + * @type {nsIUserIdleService | null} + */ + #idleService = null; + + /** + * Whether it has been initialized. + * + * @type {boolean} + */ + #init = false; + + /** + * The last Date.now() of a callback insertion. + * + * @type {number | null} + */ + #mostRecentMs = null; + + constructor() { + this.init(); + } + + init() { + if (!lazy.serpEventTelemetryCategorization || this.#init) { + return; + } + + lazy.logConsole.debug("Initializing categorization event scheduler."); + + this.#browserToCallbackMap = new WeakMap(); + + // In tests, we simulate idleness as it is more reliable and easier than + // trying to replicate idleness. The way to do is so it by creating + // an mock idle service and having the component subscribe to it. If we + // used a lazy instantiation of idle service, the test could only ever be + // subscribed to the real one. + this.#idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService( + Ci.nsIUserIdleService + ); + + this.#idleService.addIdleObserver( + this, + CATEGORIZATION_SETTINGS.IDLE_TIMEOUT_SECONDS + ); + + Services.obs.addObserver(this, "quit-application"); + Services.obs.addObserver(this, "wake_notification"); + + this.#init = true; + } + + uninit() { + if (!this.#init) { + return; + } + + this.#browserToCallbackMap = null; + + lazy.logConsole.debug("Un-initializing categorization event scheduler."); + this.#idleService.removeIdleObserver( + this, + CATEGORIZATION_SETTINGS.IDLE_TIMEOUT_SECONDS + ); + + Services.obs.removeObserver(this, "quit-application"); + Services.obs.removeObserver(this, "wake_notification"); + + this.#idleService = null; + this.#init = false; + } + + observe(subject, topic, data) { + switch (topic) { + case "idle": + lazy.logConsole.debug("Triggering all callbacks due to idle."); + this.#sendAllCallbacks(); + break; + case "quit-application": + this.uninit(); + break; + case "wake_notification": + if ( + this.#mostRecentMs && + Date.now() - this.#mostRecentMs >= + CATEGORIZATION_SETTINGS.WAKE_TIMEOUT_MS + ) { + lazy.logConsole.debug( + "Triggering all callbacks due to a wake notification." + ); + this.#sendAllCallbacks(); + } + break; + } + } + + addCallback(browser, callback) { + lazy.logConsole.debug("Adding callback to queue."); + this.#mostRecentMs = Date.now(); + this.#browserToCallbackMap?.set(browser, callback); + } + + sendCallback(browser) { + let callback = this.#browserToCallbackMap?.get(browser); + if (callback) { + lazy.logConsole.debug("Triggering callback."); + callback(); + Services.obs.notifyObservers( + null, + "recorded-single-categorization-event" + ); + this.#browserToCallbackMap.delete(browser); + } + } + + #sendAllCallbacks() { + let browsers = ChromeUtils.nondeterministicGetWeakMapKeys( + this.#browserToCallbackMap + ); + if (browsers) { + lazy.logConsole.debug("Triggering all callbacks."); + for (let browser of browsers) { + this.sendCallback(browser); + } + } + this.#mostRecentMs = null; + Services.obs.notifyObservers(null, "recorded-all-categorization-events"); + } +} + +/** + * Handles reporting SERP categorization telemetry to Glean. + */ +class CategorizationRecorder { + /** + * Helper function for recording the SERP categorization event. + * + * @param {RecordCategorizationParameters} resultToReport + * The object containing all the data required to report. + */ + recordCategorizationTelemetry(resultToReport) { + lazy.logConsole.debug( + "Reporting the following categorization result:", + resultToReport + ); + // TODO: Bug 1868476 - Report result to Glean. + } +} + +/** + * @typedef {object} DomainToCategoriesRecord + * @property {number} version + * The version of the record. + */ + +/** + * @typedef {object} DomainCategoryScore + * @property {number} category + * The index of the category. + * @property {number} score + * The score associated with the category. + */ + +/** + * Maps domain to categories, with data synced with Remote Settings. + */ +class DomainToCategoriesMap { + /** + * Contains the domain to category scores. + * + * @type {Object<string, Array<DomainCategoryScore>> | null} + */ + #map = null; + + /** + * Latest version number of the attachments. + * + * @type {number | null} + */ + #version = null; + + /** + * The Remote Settings client. + * + * @type {object | null} + */ + #client = null; + + /** + * Whether this is synced with Remote Settings. + * + * @type {boolean} + */ + #init = false; + + /** + * Callback when Remote Settings syncs. + * + * @type {Function | null} + */ + #onSettingsSync = null; + + /** + * When downloading an attachment from Remote Settings fails, this will + * contain a timer which will eventually attempt to retry downloading + * attachments. + */ + #downloadTimer = null; + + /** + * Number of times this has attempted to try another download. Will reset + * if the categorization preference has been toggled, or a sync event has + * been detected. + * + * @type {number} + */ + #downloadRetries = 0; + + /** + * Runs at application startup with startup idle tasks. If the SERP + * categorization preference is enabled, it creates a Remote Settings + * client to listen to updates, and populates the map. + */ + async init() { + if (!lazy.serpEventTelemetryCategorization || this.#init) { + return; + } + lazy.logConsole.debug("Initializing domain-to-categories map."); + this.#setupClientAndMap(); + this.#init = true; + } + + uninit() { + if (this.#init) { + lazy.logConsole.debug("Un-initializing domain-to-categories map."); + this.#clearClientAndMap(); + this.#cancelAndNullifyTimer(); + this.#init = false; + } + } + + /** + * Given a domain, find categories and relevant scores. + * + * @param {string} domain Domain to lookup. + * @returns {Array<DomainCategoryScore>} + * An array containing categories and their respective score. If no record + * for the domain is available, return an empty array. + */ + get(domain) { + if (this.empty) { + return []; + } + lazy.gCryptoHash.init(lazy.gCryptoHash.MD5); + let bytes = new TextEncoder().encode(domain); + lazy.gCryptoHash.update(bytes, domain.length); + let hash = lazy.gCryptoHash.finish(true); + let rawValues = this.#map[hash] ?? []; + if (rawValues.length) { + let output = []; + // Transform data into a more readable format. + // [x, y] => { category: x, score: y } + for (let i = 0; i < rawValues.length; i += 2) { + output.push({ category: rawValues[i], score: rawValues[i + 1] }); + } + return output; + } + return []; + } + + /** + * If the map was initialized, returns the version number for the data. + * The version number is determined by the record with the highest version + * number. Even if the records have different versions, only records from the + * latest version should be available. Returns null if the map was not + * initialized. + * + * @returns {null | number} The version number. + */ + get version() { + return this.#version; + } + + /** + * Whether the map is empty of data. + * + * @returns {boolean} + */ + get empty() { + return !this.#map; + } + + /** + * Unit test-only function, used to override the domainToCategoriesMap so + * that tests can set it to easy to test values. + * + * @param {object} domainToCategoriesMap + * An object where the key is a hashed domain and the value is an array + * containing an arbitrary number of DomainCategoryScores. + */ + overrideMapForTests(domainToCategoriesMap) { + this.#map = domainToCategoriesMap; + } + + async #setupClientAndMap() { + if (this.#client && !this.empty) { + return; + } + lazy.logConsole.debug("Setting up domain-to-categories map."); + this.#client = lazy.RemoteSettings(TELEMETRY_CATEGORIZATION_KEY); + + this.#onSettingsSync = event => this.#sync(event.data); + this.#client.on("sync", this.#onSettingsSync); + + let records = await this.#client.get(); + await this.#clearAndPopulateMap(records); + } + + #clearClientAndMap() { + if (this.#client) { + lazy.logConsole.debug("Removing Remote Settings client."); + this.#client.off("sync", this.#onSettingsSync); + this.#client = null; + this.#onSettingsSync = null; + this.#downloadRetries = 0; + } + + if (this.#map) { + lazy.logConsole.debug("Clearing domain-to-categories map."); + this.#map = null; + this.#version = null; + } + } + + /** + * Inspects a list of records from the categorization domain bucket and finds + * the maximum version score from the set of records. Each record should have + * the same version number but if for any reason one entry has a lower + * version number, the latest version can be used to filter it out. + * + * @param {Array<DomainToCategoriesRecord>} records + * An array containing the records from a Remote Settings collection. + * @returns {number} + */ + #retrieveLatestVersion(records) { + return records.reduce((version, record) => { + if (record.version > version) { + return record.version; + } + return version; + }, 0); + } + + /** + * Callback when Remote Settings has indicated the collection has been + * synced. Since the records in the collection will be updated all at once, + * use the array of current records which at this point in time would have + * the latest records from Remote Settings. Additionally, delete any + * attachment for records that no longer exist. + * + * @param {object} data + * Object containing records that are current, deleted, created, or updated. + * + */ + async #sync(data) { + lazy.logConsole.debug("Syncing domain-to-categories with Remote Settings."); + + // Remove local files of deleted records. + let toDelete = data?.deleted.filter(d => d.attachment); + await Promise.all( + toDelete.map(record => this.#client.attachments.deleteDownloaded(record)) + ); + + // In case a user encountered network failures in the past and kept their + // session on, this will ensure the next sync event will retry downloading + // again in case there's a new download error. + this.#downloadRetries = 0; + + this.#clearAndPopulateMap(data?.current); + } + + /** + * Clear the existing map and populate it with attachments found in the + * records. If no attachments are found, or no record containing an + * attachment contained the latest version, then nothing will change. + * + * @param {Array<DomainToCategoriesRecord>} records + * The records containing attachments. + * + */ + async #clearAndPopulateMap(records) { + // Set map to null so that if there are errors in the downloads, consumers + // will be able to know whether the map has information. Once we've + // successfully downloaded attachments and are parsing them, a non-null + // object will be created. + this.#map = null; + this.#version = null; + this.#cancelAndNullifyTimer(); + + if (!records?.length) { + lazy.logConsole.debug("No records found for domain-to-categories map."); + return; + } + + let fileContents = []; + for (let record of records) { + let result; + // Downloading attachments can fail. + try { + result = await this.#client.attachments.download(record); + } catch (ex) { + lazy.logConsole.error("Could not download file:", ex); + this.#createTimerToPopulateMap(); + return; + } + fileContents.push(result.buffer); + } + + // All attachments should have the same version number. If for whatever + // reason they don't, we should only use the attachments with the latest + // version. + this.#version = this.#retrieveLatestVersion(records); + + if (!this.#version) { + lazy.logConsole.debug("Could not find a version number for any record."); + return; + } + + // Queue the series of assignments. + for (let i = 0; i < fileContents.length; ++i) { + let buffer = fileContents[i]; + Services.tm.idleDispatchToMainThread(() => { + let start = Cu.now(); + let json; + try { + json = JSON.parse(new TextDecoder().decode(buffer)); + } catch (ex) { + // TODO: If there was an error decoding the buffer, we may want to + // dispatch an error in telemetry or try again. + return; + } + ChromeUtils.addProfilerMarker( + "SearchSERPTelemetry.#clearAndPopulateMap", + start, + "Convert buffer to JSON." + ); + if (!this.#map) { + this.#map = {}; + } + Object.assign(this.#map, json); + lazy.logConsole.debug("Updated domain-to-categories map."); + if (i == fileContents.length - 1) { + Services.obs.notifyObservers( + null, + "domain-to-categories-map-update-complete" + ); + } + }); + } + } + + #cancelAndNullifyTimer() { + if (this.#downloadTimer) { + lazy.logConsole.debug("Cancel and nullify download timer."); + this.#downloadTimer.cancel(); + this.#downloadTimer = null; + } + } + + #createTimerToPopulateMap() { + if ( + this.#downloadRetries >= + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxTriesPerSession + ) { + return; + } + if (!this.#downloadTimer) { + this.#downloadTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + } + lazy.logConsole.debug("Create timer to retry downloading attachments."); + let delay = + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.base + + randomInteger( + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.minAdjust, + TELEMETRY_CATEGORIZATION_DOWNLOAD_SETTINGS.maxAdjust + ); + this.#downloadTimer.initWithCallback( + async () => { + this.#downloadRetries += 1; + let records = await this.#client.get(); + this.#clearAndPopulateMap(records); + }, + delay, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } +} + +function randomInteger(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export var SearchSERPDomainToCategoriesMap = new DomainToCategoriesMap(); +export var SearchSERPTelemetry = new TelemetryHandler(); +export var SearchSERPCategorization = new SERPCategorizer(); +export var SERPCategorizationRecorder = new CategorizationRecorder(); +export var SearchSERPCategorizationEventScheduler = + new CategorizationEventScheduler(); diff --git a/browser/components/search/SearchUIUtils.sys.mjs b/browser/components/search/SearchUIUtils.sys.mjs new file mode 100644 index 0000000000..bb3e1e3c82 --- /dev/null +++ b/browser/components/search/SearchUIUtils.sys.mjs @@ -0,0 +1,120 @@ +/* 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/. */ + +/** + * Various utilities for search related UI. + */ + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "SearchUIUtilsL10n", () => { + return new Localization(["browser/search.ftl", "branding/brand.ftl"]); +}); + +export var SearchUIUtils = { + initialized: false, + + init() { + if (!this.initialized) { + Services.obs.addObserver(this, "browser-search-engine-modified"); + + this.initialized = true; + } + }, + + observe(engine, topic, data) { + switch (data) { + case "engine-default": + this.updatePlaceholderNamePreference(engine, false); + break; + case "engine-default-private": + this.updatePlaceholderNamePreference(engine, true); + break; + } + }, + + /** + * Adds an open search engine and handles error UI. + * + * @param {string} locationURL + * The URL where the OpenSearch definition is located. + * @param {string} image + * A URL string to an icon file to be used as the search engine's + * icon. This value may be overridden by an icon specified in the + * engine description file. + * @param {object} browsingContext + * The browsing context any error prompt should be opened for. + */ + async addOpenSearchEngine(locationURL, image, browsingContext) { + try { + await Services.search.addOpenSearchEngine(locationURL, image); + } catch (ex) { + let titleMsgName; + let descMsgName; + switch (ex.result) { + case Ci.nsISearchService.ERROR_DUPLICATE_ENGINE: + titleMsgName = "opensearch-error-duplicate-title"; + descMsgName = "opensearch-error-duplicate-desc"; + break; + case Ci.nsISearchService.ERROR_ENGINE_CORRUPTED: + titleMsgName = "opensearch-error-format-title"; + descMsgName = "opensearch-error-format-desc"; + break; + default: + // i.e. ERROR_DOWNLOAD_FAILURE + titleMsgName = "opensearch-error-download-title"; + descMsgName = "opensearch-error-download-desc"; + break; + } + + let [title, text] = await lazy.SearchUIUtilsL10n.formatValues([ + { + id: titleMsgName, + }, + { + id: descMsgName, + args: { + "location-url": locationURL, + }, + }, + ]); + + Services.prompt.alertBC( + browsingContext, + Ci.nsIPrompt.MODAL_TYPE_CONTENT, + title, + text + ); + return false; + } + return true; + }, + + /** + * Returns the URL to use for where to get more search engines. + * + * @returns {string} + */ + get searchEnginesURL() { + return Services.urlFormatter.formatURLPref( + "browser.search.searchEnginesURL" + ); + }, + + /** + * Update the placeholderName preference for the default search engine. + * + * @param {SearchEngine} engine The new default search engine. + * @param {boolean} isPrivate Whether this change applies to private windows. + */ + updatePlaceholderNamePreference(engine, isPrivate) { + const prefName = + "browser.urlbar.placeholderName" + (isPrivate ? ".private" : ""); + if (engine.isAppProvided) { + Services.prefs.setStringPref(prefName, engine.name); + } else { + Services.prefs.clearUserPref(prefName); + } + }, +}; diff --git a/browser/components/search/content/autocomplete-popup.js b/browser/components/search/content/autocomplete-popup.js new file mode 100644 index 0000000000..2c84bf8cd7 --- /dev/null +++ b/browser/components/search/content/autocomplete-popup.js @@ -0,0 +1,289 @@ +/* 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/. */ + +"use strict"; + +// Wrap in a block to prevent leaking to window scope. +{ + ChromeUtils.defineESModuleGetters(this, { + SearchOneOffs: "resource:///modules/SearchOneOffs.sys.mjs", + }); + + /** + * A richlistbox popup custom element for for a browser search autocomplete + * widget. + */ + class MozSearchAutocompleteRichlistboxPopup extends MozElements.MozAutocompleteRichlistboxPopup { + constructor() { + super(); + + this.addEventListener("popupshowing", event => { + // First handle deciding if we are showing the reduced version of the + // popup containing only the preferences button. We do this if the + // glass icon has been clicked if the text field is empty. + if (this.searchbar.hasAttribute("showonlysettings")) { + this.searchbar.removeAttribute("showonlysettings"); + this.setAttribute("showonlysettings", "true"); + + // Setting this with an xbl-inherited attribute gets overridden the + // second time the user clicks the glass icon for some reason... + this.richlistbox.collapsed = true; + } else { + this.removeAttribute("showonlysettings"); + // Uncollapse as long as we have a view which has >= 1 row. + // The autocomplete binding itself will take care of uncollapsing later, + // if we currently have no rows but end up having some in the future + // when the search string changes + this.richlistbox.collapsed = this.matchCount == 0; + } + + // Show the current default engine in the top header of the panel. + this.updateHeader().catch(console.error); + + this._oneOffButtons.addEventListener( + "SelectedOneOffButtonChanged", + this + ); + }); + + this.addEventListener("popuphiding", event => { + this._oneOffButtons.removeEventListener( + "SelectedOneOffButtonChanged", + this + ); + }); + + /** + * This handles clicks on the topmost "Foo Search" header in the + * popup (hbox.search-panel-header]). + */ + this.addEventListener("click", event => { + if (event.button == 2) { + // Ignore right clicks. + return; + } + let button = event.originalTarget; + let engine = button.parentNode.engine; + if (!engine) { + return; + } + this.oneOffButtons.handleSearchCommand(event, engine); + }); + + this._bundle = null; + } + + static get inheritedAttributes() { + return { + ".search-panel-current-engine": "showonlysettings", + ".searchbar-engine-image": "src", + }; + } + + // We override this because even though we have a shadow root, we want our + // inheritance to be done on the light tree. + getElementForAttrInheritance(selector) { + return this.querySelector(selector); + } + + initialize() { + super.initialize(); + this.initializeAttributeInheritance(); + + this._searchOneOffsContainer = this.querySelector(".search-one-offs"); + this._searchbarEngine = this.querySelector(".search-panel-header"); + this._searchbarEngineName = this.querySelector(".searchbar-engine-name"); + this._oneOffButtons = new SearchOneOffs(this._searchOneOffsContainer); + this._searchbar = document.getElementById("searchbar"); + } + + get oneOffButtons() { + if (!this._oneOffButtons) { + this.initialize(); + } + return this._oneOffButtons; + } + + static get markup() { + return ` + <hbox class="search-panel-header search-panel-current-engine"> + <image class="searchbar-engine-image"/> + <label class="searchbar-engine-name" flex="1" crop="end" role="presentation"/> + </hbox> + <menuseparator class="searchbar-separator"/> + <richlistbox class="autocomplete-richlistbox search-panel-tree"/> + <menuseparator class="searchbar-separator"/> + <hbox class="search-one-offs" is_searchbar="true"/> + `; + } + + get searchOneOffsContainer() { + if (!this._searchOneOffsContainer) { + this.initialize(); + } + return this._searchOneOffsContainer; + } + + get searchbarEngine() { + if (!this._searchbarEngine) { + this.initialize(); + } + return this._searchbarEngine; + } + + get searchbarEngineName() { + if (!this._searchbarEngineName) { + this.initialize(); + } + return this._searchbarEngineName; + } + + get searchbar() { + if (!this._searchbar) { + this.initialize(); + } + return this._searchbar; + } + + get bundle() { + if (!this._bundle) { + const kBundleURI = "chrome://browser/locale/search.properties"; + this._bundle = Services.strings.createBundle(kBundleURI); + } + return this._bundle; + } + + openAutocompletePopup(aInput, aElement) { + // initially the panel is hidden + // to avoid impacting startup / new window performance + aInput.popup.hidden = false; + + // this method is defined on the base binding + this._openAutocompletePopup(aInput, aElement); + } + + onPopupClick(aEvent) { + // Ignore all right-clicks + if (aEvent.button == 2) { + return; + } + + this.searchbar.telemetrySelectedIndex = this.selectedIndex; + + // Check for unmodified left-click, and use default behavior + if ( + aEvent.button == 0 && + !aEvent.shiftKey && + !aEvent.ctrlKey && + !aEvent.altKey && + !aEvent.metaKey + ) { + this.input.controller.handleEnter(true, aEvent); + return; + } + + // Check for middle-click or modified clicks on the search bar + BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod( + aEvent, + "searchbar", + this.selectedIndex + ); + + // Handle search bar popup clicks + let search = this.input.controller.getValueAt(this.selectedIndex); + + // open the search results according to the clicking subtlety + let where = whereToOpenLink(aEvent, false, true); + let params = {}; + + // But open ctrl/cmd clicks on autocomplete items in a new background tab. + let modifier = + AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey; + if ( + where == "tab" && + MouseEvent.isInstance(aEvent) && + (aEvent.button == 1 || modifier) + ) { + params.inBackground = true; + } + + // leave the popup open for background tab loads + if (!(where == "tab" && params.inBackground)) { + // close the autocomplete popup and revert the entered search term + this.closePopup(); + this.input.controller.handleEscape(); + } + + this.searchbar.doSearch(search, where, null, params); + if (where == "tab" && params.inBackground) { + this.searchbar.focus(); + } else { + this.searchbar.value = search; + } + } + + async updateHeader(engine) { + if (!engine) { + if (PrivateBrowsingUtils.isWindowPrivate(window)) { + engine = await Services.search.getDefaultPrivate(); + } else { + engine = await Services.search.getDefault(); + } + } + + let uri = engine.getIconURL(); + if (uri) { + this.setAttribute("src", uri); + } else { + // If the default has just been changed to a provider without icon, + // avoid showing the icon of the previous default provider. + this.removeAttribute("src"); + } + + let headerText = this.bundle.formatStringFromName("searchHeader", [ + engine.name, + ]); + this.searchbarEngineName.setAttribute("value", headerText); + this.searchbarEngine.engine = engine; + } + + /** + * This is called when a one-off is clicked and when "search in new tab" + * is selected from a one-off context menu. + */ + /* eslint-disable-next-line valid-jsdoc */ + handleOneOffSearch(event, engine, where, params) { + this.searchbar.handleSearchCommandWhere(event, engine, where, params); + } + + /** + * Passes DOM events for the popup to the _on_<event type> methods. + * + * @param {Event} event + * DOM event from the <popup>. + */ + handleEvent(event) { + let methodName = "_on_" + event.type; + if (methodName in this) { + this[methodName](event); + } else { + throw new Error("Unrecognized UrlbarView event: " + event.type); + } + } + _on_SelectedOneOffButtonChanged() { + let engine = + this.oneOffButtons.selectedButton && + this.oneOffButtons.selectedButton.engine; + this.updateHeader(engine).catch(console.error); + } + } + + customElements.define( + "search-autocomplete-richlistbox-popup", + MozSearchAutocompleteRichlistboxPopup, + { + extends: "panel", + } + ); +} diff --git a/browser/components/search/content/contentSearchHandoffUI.js b/browser/components/search/content/contentSearchHandoffUI.js new file mode 100644 index 0000000000..7c2aaa71b7 --- /dev/null +++ b/browser/components/search/content/contentSearchHandoffUI.js @@ -0,0 +1,152 @@ +/* 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/. */ + +"use strict"; + +function ContentSearchHandoffUIController() { + this._isPrivateEngine = false; + this._isAboutPrivateBrowsing = false; + this._engineIcon = null; + + window.addEventListener("ContentSearchService", this); + this._sendMsg("GetEngine"); + this._sendMsg("GetHandoffSearchModePrefs"); +} + +ContentSearchHandoffUIController.prototype = { + handleEvent(event) { + let methodName = "_onMsg" + event.detail.type; + if (methodName in this) { + this[methodName](event.detail.data); + } + }, + + get defaultEngine() { + return this._defaultEngine; + }, + + _onMsgEngine({ isPrivateEngine, isAboutPrivateBrowsing, engine }) { + this._isPrivateEngine = isPrivateEngine; + this._isAboutPrivateBrowsing = isAboutPrivateBrowsing; + this._updateEngine(engine); + }, + + _onMsgCurrentEngine(engine) { + if (!this._isPrivateEngine) { + this._updateEngine(engine); + } + }, + + _onMsgCurrentPrivateEngine(engine) { + if (this._isPrivateEngine) { + this._updateEngine(engine); + } + }, + + _onMsgHandoffSearchModePrefs(pref) { + this._shouldHandOffToSearchMode = pref; + this._updatel10nIds(); + }, + + _updateEngine(engine) { + this._defaultEngine = engine; + if (this._engineIcon) { + URL.revokeObjectURL(this._engineIcon); + } + + // We only show the engines icon for app provided engines, otherwise show + // a default. xref https://bugzilla.mozilla.org/show_bug.cgi?id=1449338#c19 + if (!engine.isAppProvided) { + this._engineIcon = "chrome://global/skin/icons/search-glass.svg"; + } else if (engine.iconData) { + this._engineIcon = this._getFaviconURIFromIconData(engine.iconData); + } else { + this._engineIcon = "chrome://global/skin/icons/defaultFavicon.svg"; + } + + document.body.style.setProperty( + "--newtab-search-icon", + "url(" + this._engineIcon + ")" + ); + this._updatel10nIds(); + }, + + _updatel10nIds() { + let engine = this._defaultEngine; + let fakeButton = document.querySelector(".search-handoff-button"); + let fakeInput = document.querySelector(".fake-textbox"); + if (!fakeButton || !fakeInput) { + return; + } + if (!engine || this._shouldHandOffToSearchMode) { + document.l10n.setAttributes( + fakeButton, + this._isAboutPrivateBrowsing + ? "about-private-browsing-search-btn" + : "newtab-search-box-input" + ); + document.l10n.setAttributes( + fakeInput, + this._isAboutPrivateBrowsing + ? "about-private-browsing-search-placeholder" + : "newtab-search-box-text" + ); + } else if (!engine.isAppProvided) { + document.l10n.setAttributes( + fakeButton, + this._isAboutPrivateBrowsing + ? "about-private-browsing-handoff-no-engine" + : "newtab-search-box-handoff-input-no-engine" + ); + document.l10n.setAttributes( + fakeInput, + this._isAboutPrivateBrowsing + ? "about-private-browsing-handoff-text-no-engine" + : "newtab-search-box-handoff-text-no-engine" + ); + } else { + document.l10n.setAttributes( + fakeButton, + this._isAboutPrivateBrowsing + ? "about-private-browsing-handoff" + : "newtab-search-box-handoff-input", + { + engine: engine.name, + } + ); + document.l10n.setAttributes( + fakeInput, + this._isAboutPrivateBrowsing + ? "about-private-browsing-handoff-text" + : "newtab-search-box-handoff-text", + { + engine: engine.name, + } + ); + } + }, + + // If the favicon is an array buffer, convert it into a Blob URI. + // Otherwise just return the plain URI. + _getFaviconURIFromIconData(data) { + if (typeof data === "string") { + return data; + } + + // If typeof(data) != "string", we assume it's an ArrayBuffer + let blob = new Blob([data]); + return URL.createObjectURL(blob); + }, + + _sendMsg(type, data = null) { + dispatchEvent( + new CustomEvent("ContentSearchClient", { + detail: { + type, + data, + }, + }) + ); + }, +}; diff --git a/browser/components/search/content/contentSearchUI.css b/browser/components/search/content/contentSearchUI.css new file mode 100644 index 0000000000..85b718a3eb --- /dev/null +++ b/browser/components/search/content/contentSearchUI.css @@ -0,0 +1,160 @@ +/* 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/. */ + +.contentSearchSuggestionTable { + background-color: hsla(0,0%,100%,.99); + color: black; + border: 1px solid hsla(0, 0%, 0%, .2); + border-top: none; + box-shadow: 0 5px 10px hsla(0, 0%, 0%, .1); + position: absolute; + inset-inline-start: 0; + z-index: 1001; + user-select: none; + cursor: default; +} + +.contentSearchSuggestionsList { + border-bottom: 1px solid hsl(0, 0%, 92%); + width: 100%; + height: 100%; +} + +.contentSearchSuggestionTable, +.contentSearchSuggestionsList { + border-spacing: 0; + overflow: hidden; + padding: 0; + margin: 0; + text-align: start; +} + +.contentSearchHeaderRow, +.contentSearchSuggestionRow { + margin: 0; + max-width: inherit; + padding: 0; +} + +.contentSearchHeaderRow > td > img, +.contentSearchSuggestionRow > td > .historyIcon { + margin-inline-end: 8px; + margin-bottom: -3px; +} + +.contentSearchSuggestionTable .historyIcon { + width: 16px; + height: 16px; + display: inline-block; + background-image: url("chrome://browser/skin/history.svg"); + -moz-context-properties: fill; + fill: graytext; +} + +.contentSearchSuggestionRow.selected > td > .historyIcon { + fill: HighlightText; +} + +.contentSearchHeader > img { + height: 16px; + width: 16px; + margin: 0; + padding: 0; +} + +.contentSearchSuggestionRow.remote > td > .historyIcon { + visibility: hidden; +} + +.contentSearchSuggestionRow.selected { + background-color: SelectedItem; + color: SelectedItemText; +} + +.contentSearchHeader, +.contentSearchSuggestionEntry { + margin: 0; + max-width: inherit; + overflow: hidden; + padding: 4px 10px; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 75%; +} + +.contentSearchHeader { + background-color: hsl(0, 0%, 97%); + color: #666; + border-bottom: 1px solid hsl(0, 0%, 92%); +} + +.contentSearchSuggestionsContainer { + margin: 0; + padding: 0; + border-spacing: 0; + width: 100%; +} + +.contentSearchSearchWithHeaderSearchText { + white-space: pre; + font-weight: bold; +} + +.contentSearchOneOffItem { + appearance: none; + height: 32px; + margin: 0; + padding: 0; + border: none; + background: none; + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAWCAYAAAABxvaqAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3gofECQNNVW2/AAAABBJREFUGFdjOHPmzH8GehEA/KpKg9YTf4AAAAAASUVORK5CYII='); + background-repeat: no-repeat; + background-position: right center; +} + +.contentSearchOneOffItem:dir(rtl) { + background-position-x: left; +} + +.contentSearchOneOffItem > img { + width: 16px; + height: 16px; + margin-bottom: -2px; + pointer-events: none; +} + +.contentSearchOneOffItem:not(.last-row) { + border-bottom: 1px solid hsl(0, 0%, 92%); +} + +.contentSearchOneOffItem.end-of-row { + background-image: none; +} + +.contentSearchOneOffItem.selected { + background-color: SelectedItem; + background-image: none; +} + +.contentSearchOneOffsTable { + width: 100%; +} + +.contentSearchSettingsButton { + margin: 0; + padding: 0; + height: 32px; + border: none; + border-top: 1px solid hsla(0, 0%, 0%, .08); + text-align: center; + width: 100%; +} + +.contentSearchSettingsButton.selected { + background-color: hsl(0, 0%, 90%); +} + +.contentSearchSettingsButton:active { + background-color: hsl(0, 0%, 85%); +} diff --git a/browser/components/search/content/contentSearchUI.js b/browser/components/search/content/contentSearchUI.js new file mode 100644 index 0000000000..9c7387d364 --- /dev/null +++ b/browser/components/search/content/contentSearchUI.js @@ -0,0 +1,1021 @@ +/* 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/. */ + +"use strict"; + +this.ContentSearchUIController = (function () { + const MAX_DISPLAYED_SUGGESTIONS = 6; + const SUGGESTION_ID_PREFIX = "searchSuggestion"; + const ONE_OFF_ID_PREFIX = "oneOff"; + const HTML_NS = "http://www.w3.org/1999/xhtml"; + + /** + * Creates a new object that manages search suggestions and their UI for a text + * box. + * + * The UI consists of an html:table that's inserted into the DOM after the given + * text box and styled so that it appears as a dropdown below the text box. + * + * @param {DOMElement} inputElement + * Search suggestions will be based on the text in this text box. + * Assumed to be an html:input. + * @param {DOMElement} tableParent + * The suggestion table is appended as a child to this element. Since + * the table is absolutely positioned and its top and left values are set + * to be relative to the top and left of the page, either the parent and + * all its ancestors should not be positioned elements (i.e., their + * positions should be "static"), or the parent's position should be the + * top left of the page. + * @param {string} healthReportKey + * This will be sent with the search data for BrowserUsageTelemetry to + * record the search. + * @param {string} searchPurpose + * Sent with search data, see nsISearchEngine.getSubmission. + * @param {sring} idPrefix + * The IDs of elements created by the object will be prefixed with this + * string. + */ + function ContentSearchUIController( + inputElement, + tableParent, + healthReportKey, + searchPurpose, + idPrefix = "" + ) { + this.input = inputElement; + this._idPrefix = idPrefix; + this._healthReportKey = healthReportKey; + this._searchPurpose = searchPurpose; + this._isPrivateEngine = false; + + let tableID = idPrefix + "searchSuggestionTable"; + this.input.autocomplete = "off"; + this.input.setAttribute("aria-autocomplete", "true"); + this.input.setAttribute("aria-controls", tableID); + tableParent.appendChild(this._makeTable(tableID)); + + this.input.addEventListener("keydown", this); + this.input.addEventListener("input", this); + this.input.addEventListener("focus", this); + this.input.addEventListener("blur", this); + window.addEventListener("ContentSearchService", this); + + this._stickyInputValue = ""; + this._hideSuggestions(); + + this._getSearchEngines(); + this._getStrings(); + } + + ContentSearchUIController.prototype = { + _oneOffButtons: [], + // Setting up the one off buttons causes an uninterruptible reflow. If we + // receive the list of engines while the newtab page is loading, this reflow + // may regress performance - so we set this flag and only set up the buttons + // if it's set when the suggestions table is actually opened. + _pendingOneOffRefresh: undefined, + + get defaultEngine() { + return this._defaultEngine; + }, + + set defaultEngine(engine) { + if (this._defaultEngine && this._defaultEngine.icon) { + URL.revokeObjectURL(this._defaultEngine.icon); + } + let icon; + if (engine.iconData) { + icon = this._getFaviconURIFromIconData(engine.iconData); + } else { + icon = "chrome://global/skin/icons/defaultFavicon.svg"; + } + this._defaultEngine = { + name: engine.name, + icon, + isAppProvided: engine.isAppProvided, + }; + this._updateDefaultEngineHeader(); + this._updateDefaultEngineIcon(); + + if (engine && document.activeElement == this.input) { + this._speculativeConnect(); + } + }, + + get engines() { + return this._engines; + }, + + set engines(val) { + this._engines = val; + this._pendingOneOffRefresh = true; + }, + + // The selectedIndex is the index of the element with the "selected" class in + // the list obtained by concatenating the suggestion rows, one-off buttons, and + // search settings button. + get selectedIndex() { + let allElts = [ + ...this._suggestionsList.children, + ...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton"), + ]; + for (let i = 0; i < allElts.length; ++i) { + let elt = allElts[i]; + if (elt.classList.contains("selected")) { + return i; + } + } + return -1; + }, + + set selectedIndex(idx) { + // Update the table's rows, and the input when there is a selection. + this._table.removeAttribute("aria-activedescendant"); + this.input.removeAttribute("aria-activedescendant"); + + let allElts = [ + ...this._suggestionsList.children, + ...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton"), + ]; + // If we are selecting a suggestion and a one-off is selected, don't deselect it. + let excludeIndex = + idx < this.numSuggestions && this.selectedButtonIndex > -1 + ? this.numSuggestions + this.selectedButtonIndex + : -1; + for (let i = 0; i < allElts.length; ++i) { + let elt = allElts[i]; + let ariaSelectedElt = i < this.numSuggestions ? elt.firstChild : elt; + if (i == idx) { + elt.classList.add("selected"); + ariaSelectedElt.setAttribute("aria-selected", "true"); + this.input.setAttribute("aria-activedescendant", ariaSelectedElt.id); + } else if (i != excludeIndex) { + elt.classList.remove("selected"); + ariaSelectedElt.setAttribute("aria-selected", "false"); + } + } + }, + + get selectedButtonIndex() { + let elts = [ + ...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton"), + ]; + for (let i = 0; i < elts.length; ++i) { + if (elts[i].classList.contains("selected")) { + return i; + } + } + return -1; + }, + + set selectedButtonIndex(idx) { + let elts = [ + ...this._oneOffButtons, + document.getElementById("contentSearchSettingsButton"), + ]; + for (let i = 0; i < elts.length; ++i) { + let elt = elts[i]; + if (i == idx) { + elt.classList.add("selected"); + elt.setAttribute("aria-selected", "true"); + } else { + elt.classList.remove("selected"); + elt.setAttribute("aria-selected", "false"); + } + } + }, + + get selectedEngineName() { + let selectedElt = this._oneOffsTable.querySelector(".selected"); + if (selectedElt) { + return selectedElt.engineName; + } + return this.defaultEngine.name; + }, + + get numSuggestions() { + return this._suggestionsList.children.length; + }, + + selectAndUpdateInput(idx) { + this.selectedIndex = idx; + let newValue = this.suggestionAtIndex(idx) || this._stickyInputValue; + // Setting the input value when the value has not changed commits the current + // IME composition, which we don't want to do. + if (this.input.value != newValue) { + this.input.value = newValue; + } + this._updateSearchWithHeader(); + }, + + suggestionAtIndex(idx) { + let row = this._suggestionsList.children[idx]; + return row ? row.textContent : null; + }, + + deleteSuggestionAtIndex(idx) { + // Only form history suggestions can be deleted. + if (this.isFormHistorySuggestionAtIndex(idx)) { + let suggestionStr = this.suggestionAtIndex(idx); + this._sendMsg("RemoveFormHistoryEntry", suggestionStr); + this._suggestionsList.children[idx].remove(); + this.selectAndUpdateInput(-1); + } + }, + + isFormHistorySuggestionAtIndex(idx) { + let row = this._suggestionsList.children[idx]; + return row && row.classList.contains("formHistory"); + }, + + addInputValueToFormHistory() { + let entry = { + value: this.input.value, + engineName: this.selectedEngineName, + }; + this._sendMsg("AddFormHistoryEntry", entry); + return entry; + }, + + handleEvent(event) { + // The event handler is triggered by external events while the search + // element may no longer be present + if (!document.contains(this.input)) { + return; + } + this["_on" + event.type[0].toUpperCase() + event.type.substr(1)](event); + }, + + _onCommand(aEvent) { + if (this.selectedButtonIndex == this._oneOffButtons.length) { + // Settings button was selected. + this._sendMsg("ManageEngines"); + return; + } + + this.search(aEvent); + + if (aEvent) { + aEvent.preventDefault(); + } + }, + + search(aEvent) { + if (!this.defaultEngine) { + return; // Not initialized yet. + } + + let searchText = this.input; + let searchTerms; + if ( + this._table.hidden || + (aEvent.originalTarget && + aEvent.originalTarget.id == "contentSearchDefaultEngineHeader") || + aEvent instanceof KeyboardEvent + ) { + searchTerms = searchText.value; + } else { + searchTerms = + this.suggestionAtIndex(this.selectedIndex) || searchText.value; + } + // Send an event that will perform a search and Firefox Health Report will + // record that a search from the healthReportKey passed to the constructor. + let eventData = { + engineName: this.selectedEngineName, + searchString: searchTerms, + healthReportKey: this._healthReportKey, + searchPurpose: this._searchPurpose, + originalEvent: { + shiftKey: aEvent.shiftKey, + ctrlKey: aEvent.ctrlKey, + metaKey: aEvent.metaKey, + altKey: aEvent.altKey, + }, + }; + if ("button" in aEvent) { + eventData.originalEvent.button = aEvent.button; + } + + if (this.suggestionAtIndex(this.selectedIndex)) { + eventData.selection = { + index: this.selectedIndex, + kind: undefined, + }; + if (aEvent instanceof MouseEvent) { + eventData.selection.kind = "mouse"; + } else if (aEvent instanceof KeyboardEvent) { + eventData.selection.kind = "key"; + } + } + + this._sendMsg("Search", eventData); + this.addInputValueToFormHistory(); + }, + + _onInput() { + if (!this.input.value) { + this._stickyInputValue = ""; + this._hideSuggestions(); + } else if (this.input.value != this._stickyInputValue) { + // Only fetch new suggestions if the input value has changed. + this._getSuggestions(); + this.selectAndUpdateInput(-1); + } + this._updateSearchWithHeader(); + }, + + _onKeydown(event) { + let selectedIndexDelta = 0; + let selectedSuggestionDelta = 0; + let selectedOneOffDelta = 0; + + switch (event.keyCode) { + case event.DOM_VK_UP: + if (this._table.hidden) { + return; + } + if (event.getModifierState("Accel")) { + if (event.shiftKey) { + selectedSuggestionDelta = -1; + break; + } + this._cycleCurrentEngine(true); + break; + } + if (event.altKey) { + selectedOneOffDelta = -1; + break; + } + selectedIndexDelta = -1; + break; + case event.DOM_VK_DOWN: + if (this._table.hidden) { + this._getSuggestions(); + return; + } + if (event.getModifierState("Accel")) { + if (event.shiftKey) { + selectedSuggestionDelta = 1; + break; + } + this._cycleCurrentEngine(false); + break; + } + if (event.altKey) { + selectedOneOffDelta = 1; + break; + } + selectedIndexDelta = 1; + break; + case event.DOM_VK_TAB: + if (this._table.hidden) { + return; + } + // Shift+tab when either the first or no one-off is selected, as well as + // tab when the settings button is selected, should change focus as normal. + if ( + (this.selectedButtonIndex <= 0 && event.shiftKey) || + (this.selectedButtonIndex == this._oneOffButtons.length && + !event.shiftKey) + ) { + return; + } + selectedOneOffDelta = event.shiftKey ? -1 : 1; + break; + case event.DOM_VK_RIGHT: + // Allow normal caret movement until the caret is at the end of the input. + if ( + this.input.selectionStart != this.input.selectionEnd || + this.input.selectionEnd != this.input.value.length + ) { + return; + } + if ( + this.numSuggestions && + this.selectedIndex >= 0 && + this.selectedIndex < this.numSuggestions + ) { + this.input.value = this.suggestionAtIndex(this.selectedIndex); + this.input.setAttribute("selection-index", this.selectedIndex); + this.input.setAttribute("selection-kind", "key"); + } else { + // If we didn't select anything, make sure to remove the attributes + // in case they were populated last time. + this.input.removeAttribute("selection-index"); + this.input.removeAttribute("selection-kind"); + } + this._stickyInputValue = this.input.value; + this._hideSuggestions(); + return; + case event.DOM_VK_RETURN: + this._onCommand(event); + return; + case event.DOM_VK_DELETE: + if (this.selectedIndex >= 0) { + this.deleteSuggestionAtIndex(this.selectedIndex); + } + return; + case event.DOM_VK_ESCAPE: + if (!this._table.hidden) { + this._hideSuggestions(); + } + return; + default: + return; + } + + let currentIndex = this.selectedIndex; + if (selectedIndexDelta) { + let newSelectedIndex = currentIndex + selectedIndexDelta; + if (newSelectedIndex < -1) { + newSelectedIndex = this.numSuggestions + this._oneOffButtons.length; + } + // If are moving up from the first one off, we have to deselect the one off + // manually because the selectedIndex setter tries to exclude the selected + // one-off (which is desirable for accel+shift+up/down). + if (currentIndex == this.numSuggestions && selectedIndexDelta == -1) { + this.selectedButtonIndex = -1; + } + this.selectAndUpdateInput(newSelectedIndex); + } else if (selectedSuggestionDelta) { + let newSelectedIndex; + if (currentIndex >= this.numSuggestions || currentIndex == -1) { + // No suggestion already selected, select the first/last one appropriately. + newSelectedIndex = + selectedSuggestionDelta == 1 ? 0 : this.numSuggestions - 1; + } else { + newSelectedIndex = currentIndex + selectedSuggestionDelta; + } + if (newSelectedIndex >= this.numSuggestions) { + newSelectedIndex = -1; + } + this.selectAndUpdateInput(newSelectedIndex); + } else if (selectedOneOffDelta) { + let newSelectedIndex; + let currentButton = this.selectedButtonIndex; + if ( + currentButton == -1 || + currentButton == this._oneOffButtons.length + ) { + // No one-off already selected, select the first/last one appropriately. + newSelectedIndex = + selectedOneOffDelta == 1 ? 0 : this._oneOffButtons.length - 1; + } else { + newSelectedIndex = currentButton + selectedOneOffDelta; + } + // Allow selection of the settings button via the tab key. + if ( + newSelectedIndex == this._oneOffButtons.length && + event.keyCode != event.DOM_VK_TAB + ) { + newSelectedIndex = -1; + } + this.selectedButtonIndex = newSelectedIndex; + } + + // Prevent the input's caret from moving. + event.preventDefault(); + }, + + _currentEngineIndex: -1, + _cycleCurrentEngine(aReverse) { + if ( + (this._currentEngineIndex == this._engines.length - 1 && !aReverse) || + (this._currentEngineIndex == 0 && aReverse) + ) { + return; + } + this._currentEngineIndex += aReverse ? -1 : 1; + let engineName = this._engines[this._currentEngineIndex].name; + this._sendMsg("SetCurrentEngine", engineName); + }, + + _onFocus() { + if (this._mousedown) { + return; + } + // When the input box loses focus to something in our table, we refocus it + // immediately. This causes the focus highlight to flicker, so we set a + // custom attribute which consumers should use for focus highlighting. This + // attribute is removed only when we do not immediately refocus the input + // box, thus eliminating flicker. + this.input.setAttribute("keepfocus", "true"); + this._speculativeConnect(); + }, + + _onBlur() { + if (this._mousedown) { + // At this point, this.input has lost focus, but a new element has not yet + // received it. If we re-focus this.input directly, the new element will + // steal focus immediately, so we queue it instead. + setTimeout(() => this.input.focus(), 0); + return; + } + this.input.removeAttribute("keepfocus"); + this._hideSuggestions(); + }, + + _onMousemove(event) { + let idx = this._indexOfTableItem(event.target); + if (idx >= this.numSuggestions) { + // Deselect any search suggestion that has been selected. + this.selectedIndex = -1; + this.selectedButtonIndex = idx - this.numSuggestions; + return; + } + this.selectedIndex = idx; + }, + + _onMouseup(event) { + if (event.button == 2) { + return; + } + this._onCommand(event); + }, + + _onMouseout(event) { + // We only deselect one-off buttons and the settings button when they are + // moused out. + let idx = this._indexOfTableItem(event.originalTarget); + if (idx >= this.numSuggestions) { + this.selectedButtonIndex = -1; + } + }, + + _onClick(event) { + this._onMouseup(event); + }, + + _onContentSearchService(event) { + let methodName = "_onMsg" + event.detail.type; + if (methodName in this) { + this[methodName](event.detail.data); + } + }, + + _onMsgFocusInput(event) { + this.input.focus(); + }, + + _onMsgBlur(event) { + this.input.blur(); + this._hideSuggestions(); + }, + + _onMsgSuggestions(suggestions) { + // Ignore the suggestions if their search string or engine doesn't match + // ours. Due to the async nature of message passing, this can easily happen + // when the user types quickly. + if ( + this._stickyInputValue != suggestions.searchString || + this.defaultEngine.name != suggestions.engineName + ) { + return; + } + + this._clearSuggestionRows(); + + // Position and size the table. + let { left } = this.input.getBoundingClientRect(); + this._table.style.top = this.input.offsetHeight + "px"; + this._table.style.minWidth = this.input.offsetWidth + "px"; + this._table.style.maxWidth = window.innerWidth - left - 40 + "px"; + + // Add the suggestions to the table. + let searchWords = new Set( + suggestions.searchString.trim().toLowerCase().split(/\s+/) + ); + for (let i = 0; i < MAX_DISPLAYED_SUGGESTIONS; i++) { + let type, idx; + if (i < suggestions.formHistory.length) { + [type, idx] = ["formHistory", i]; + } else { + let j = i - suggestions.formHistory.length; + if (j < suggestions.remote.length) { + [type, idx] = ["remote", j]; + } else { + break; + } + } + this._suggestionsList.appendChild( + this._makeTableRow(type, suggestions[type][idx], i, searchWords) + ); + } + + if (this._table.hidden) { + this.selectedIndex = -1; + if (this._pendingOneOffRefresh) { + this._setUpOneOffButtons(); + delete this._pendingOneOffRefresh; + } + this._currentEngineIndex = this._engines.findIndex( + aEngine => aEngine.name == this.defaultEngine.name + ); + this._table.hidden = false; + this.input.setAttribute("aria-expanded", "true"); + } + }, + + _onMsgSuggestionsCancelled() { + if (!this._table.hidden) { + this._hideSuggestions(); + } + }, + + _onMsgState(state) { + // Not all state messages broadcast the windows' privateness info. + if ("isPrivateWindow" in state) { + this._isPrivateEngine = state.isPrivateEngine; + } + + this.engines = state.engines; + + let currentEngine = state.currentEngine; + if (this._isPrivateEngine) { + currentEngine = state.currentPrivateEngine; + } + + // No point updating the default engine (and the header) if there's no change. + if ( + this.defaultEngine && + this.defaultEngine.name == currentEngine.name && + this.defaultEngine.icon == currentEngine.icon + ) { + return; + } + this.defaultEngine = currentEngine; + }, + + _onMsgCurrentState(state) { + this._onMsgState(state); + }, + + _onMsgCurrentEngine(engine) { + if (this._isPrivateEngine) { + return; + } + this.defaultEngine = engine; + this._pendingOneOffRefresh = true; + }, + + _onMsgCurrentPrivateEngine(engine) { + if (!this._isPrivateEngine) { + return; + } + this.defaultEngine = engine; + this._pendingOneOffRefresh = true; + }, + + _onMsgStrings(strings) { + this._strings = strings; + this._updateDefaultEngineHeader(); + this._updateSearchWithHeader(); + document.getElementById("contentSearchSettingsButton").textContent = + this._strings.searchSettings; + }, + + _updateDefaultEngineIcon() { + // 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 + let icon = this.defaultEngine.isAppProvided + ? this.defaultEngine.icon + : "chrome://global/skin/icons/search-glass.svg"; + + document.body.style.setProperty( + "--newtab-search-icon", + "url(" + icon + ")" + ); + }, + + _updateDefaultEngineHeader() { + let header = document.getElementById("contentSearchDefaultEngineHeader"); + header.firstChild.setAttribute("src", this.defaultEngine.icon); + if (!this._strings) { + return; + } + while (header.firstChild.nextSibling) { + header.firstChild.nextSibling.remove(); + } + header.appendChild( + document.createTextNode( + this._strings.searchHeader.replace("%S", this.defaultEngine.name) + ) + ); + }, + + _updateSearchWithHeader() { + if (!this._strings) { + return; + } + let searchWithHeader = document.getElementById( + "contentSearchSearchWithHeader" + ); + let labels = searchWithHeader.querySelectorAll("label"); + if (this.input.value) { + let header = this._strings.searchForSomethingWith2; + // Translators can use both %S and %1$S. + header = header.replace("%1$S", "%S").split("%S"); + labels[0].textContent = header[0]; + labels[1].textContent = this.input.value; + labels[2].textContent = header[1]; + } else { + labels[0].textContent = this._strings.searchWithHeader; + labels[1].textContent = ""; + labels[2].textContent = ""; + } + }, + + _speculativeConnect() { + if (this.defaultEngine) { + this._sendMsg("SpeculativeConnect", this.defaultEngine.name); + } + }, + + _makeTableRow(type, suggestionStr, currentRow, searchWords) { + let row = document.createElementNS(HTML_NS, "tr"); + row.dir = "auto"; + row.classList.add("contentSearchSuggestionRow"); + row.classList.add(type); + row.setAttribute("role", "presentation"); + row.addEventListener("mousemove", this); + row.addEventListener("mouseup", this); + + let entry = document.createElementNS(HTML_NS, "td"); + let img = document.createElementNS(HTML_NS, "div"); + img.setAttribute("class", "historyIcon"); + entry.appendChild(img); + entry.classList.add("contentSearchSuggestionEntry"); + entry.setAttribute("role", "option"); + entry.id = this._idPrefix + SUGGESTION_ID_PREFIX + currentRow; + entry.setAttribute("aria-selected", "false"); + + let suggestionWords = suggestionStr.trim().toLowerCase().split(/\s+/); + for (let i = 0; i < suggestionWords.length; i++) { + let word = suggestionWords[i]; + let wordSpan = document.createElementNS(HTML_NS, "span"); + if (searchWords.has(word)) { + wordSpan.classList.add("typed"); + } + wordSpan.textContent = word; + entry.appendChild(wordSpan); + if (i < suggestionWords.length - 1) { + entry.appendChild(document.createTextNode(" ")); + } + } + + row.appendChild(entry); + return row; + }, + + // If the favicon is an array buffer, convert it into a Blob URI. + // Otherwise just return the plain URI. + _getFaviconURIFromIconData(data) { + if (typeof data == "string") { + return data; + } + + // If typeof(data) != "string", we assume it's an ArrayBuffer + let blob = new Blob([data]); + return URL.createObjectURL(blob); + }, + + // Adds "@2x" to the name of the given PNG url for "retina" screens. + _getImageURIForCurrentResolution(uri) { + if (window.devicePixelRatio > 1) { + return uri.replace(/\.png$/, "@2x.png"); + } + return uri; + }, + + _getSearchEngines() { + this._sendMsg("GetState"); + }, + + _getStrings() { + this._sendMsg("GetStrings"); + }, + + _getSuggestions() { + this._stickyInputValue = this.input.value; + if (this.defaultEngine) { + this._sendMsg("GetSuggestions", { + engineName: this.defaultEngine.name, + searchString: this.input.value, + }); + } + }, + + _clearSuggestionRows() { + while (this._suggestionsList.firstElementChild) { + this._suggestionsList.firstElementChild.remove(); + } + }, + + _hideSuggestions() { + this.input.setAttribute("aria-expanded", "false"); + this.selectedIndex = -1; + this.selectedButtonIndex = -1; + this._currentEngineIndex = -1; + this._table.hidden = true; + }, + + _indexOfTableItem(elt) { + if (elt.classList.contains("contentSearchOneOffItem")) { + return this.numSuggestions + this._oneOffButtons.indexOf(elt); + } + if (elt.classList.contains("contentSearchSettingsButton")) { + return this.numSuggestions + this._oneOffButtons.length; + } + while (elt && elt.localName != "tr") { + elt = elt.parentNode; + } + if (!elt) { + throw new Error("Element is not a row"); + } + return elt.rowIndex; + }, + + _makeTable(id) { + this._table = document.createElementNS(HTML_NS, "table"); + this._table.id = id; + this._table.hidden = true; + this._table.classList.add("contentSearchSuggestionTable"); + this._table.setAttribute("role", "presentation"); + + // When the search input box loses focus, we want to immediately give focus + // back to it if the blur was because the user clicked somewhere in the table. + // onBlur uses the _mousedown flag to detect this. + this._table.addEventListener("mousedown", () => { + this._mousedown = true; + }); + document.addEventListener("mouseup", () => { + delete this._mousedown; + }); + + // Deselect the selected element on mouseout if it wasn't a suggestion. + this._table.addEventListener("mouseout", this); + + let headerRow = document.createElementNS(HTML_NS, "tr"); + let header = document.createElementNS(HTML_NS, "td"); + headerRow.setAttribute("class", "contentSearchHeaderRow"); + header.setAttribute("class", "contentSearchHeader"); + let iconImg = document.createElementNS(HTML_NS, "img"); + header.appendChild(iconImg); + header.id = "contentSearchDefaultEngineHeader"; + headerRow.appendChild(header); + headerRow.addEventListener("click", this); + this._table.appendChild(headerRow); + + let row = document.createElementNS(HTML_NS, "tr"); + row.setAttribute("class", "contentSearchSuggestionsContainer"); + let cell = document.createElementNS(HTML_NS, "td"); + cell.setAttribute("class", "contentSearchSuggestionsContainer"); + this._suggestionsList = document.createElementNS(HTML_NS, "table"); + this._suggestionsList.setAttribute( + "class", + "contentSearchSuggestionsList" + ); + cell.appendChild(this._suggestionsList); + row.appendChild(cell); + this._table.appendChild(row); + this._suggestionsList.setAttribute("role", "listbox"); + + this._oneOffsTable = document.createElementNS(HTML_NS, "table"); + this._oneOffsTable.setAttribute("class", "contentSearchOneOffsTable"); + this._oneOffsTable.classList.add("contentSearchSuggestionsContainer"); + this._oneOffsTable.setAttribute("role", "group"); + this._table.appendChild(this._oneOffsTable); + + headerRow = document.createElementNS(HTML_NS, "tr"); + header = document.createElementNS(HTML_NS, "td"); + headerRow.setAttribute("class", "contentSearchHeaderRow"); + header.setAttribute("class", "contentSearchHeader"); + headerRow.appendChild(header); + header.id = "contentSearchSearchWithHeader"; + let start = document.createElement("label"); + let inputLabel = document.createElement("label"); + inputLabel.setAttribute( + "class", + "contentSearchSearchWithHeaderSearchText" + ); + let end = document.createElement("label"); + header.appendChild(start); + header.appendChild(inputLabel); + header.appendChild(end); + this._oneOffsTable.appendChild(headerRow); + + let button = document.createElementNS(HTML_NS, "button"); + button.setAttribute("class", "contentSearchSettingsButton"); + button.classList.add("contentSearchHeaderRow"); + button.classList.add("contentSearchHeader"); + button.id = "contentSearchSettingsButton"; + button.addEventListener("click", this); + button.addEventListener("mousemove", this); + this._table.appendChild(button); + + return this._table; + }, + + _setUpOneOffButtons() { + // Sometimes we receive a CurrentEngine message from the ContentSearch service + // before we've received a State message - i.e. before we have our engines. + if (!this._engines) { + return; + } + + while (this._oneOffsTable.firstChild.nextSibling) { + this._oneOffsTable.firstChild.nextSibling.remove(); + } + + this._oneOffButtons = []; + + let engines = this._engines + .filter(aEngine => aEngine.name != this.defaultEngine.name) + .filter(aEngine => !aEngine.hidden); + if (!engines.length) { + this._oneOffsTable.hidden = true; + return; + } + + const kDefaultButtonWidth = 49; // 48px + 1px border. + let rowWidth = this.input.offsetWidth - 2; // 2px border. + let enginesPerRow = Math.floor(rowWidth / kDefaultButtonWidth); + let buttonWidth = Math.floor(rowWidth / enginesPerRow); + + let row = document.createElementNS(HTML_NS, "tr"); + let cell = document.createElementNS(HTML_NS, "td"); + row.setAttribute("class", "contentSearchSuggestionsContainer"); + cell.setAttribute("class", "contentSearchSuggestionsContainer"); + + for (let i = 0; i < engines.length; ++i) { + let engine = engines[i]; + if (i > 0 && i % enginesPerRow == 0) { + row.appendChild(cell); + this._oneOffsTable.appendChild(row); + row = document.createElementNS(HTML_NS, "tr"); + cell = document.createElementNS(HTML_NS, "td"); + row.setAttribute("class", "contentSearchSuggestionsContainer"); + cell.setAttribute("class", "contentSearchSuggestionsContainer"); + } + let button = document.createElementNS(HTML_NS, "button"); + button.setAttribute("class", "contentSearchOneOffItem"); + let img = document.createElementNS(HTML_NS, "img"); + let uri; + if (engine.iconData) { + uri = this._getFaviconURIFromIconData(engine.iconData); + } else { + uri = this._getImageURIForCurrentResolution( + "chrome://browser/skin/search-engine-placeholder.png" + ); + } + img.setAttribute("src", uri); + img.addEventListener( + "load", + function () { + URL.revokeObjectURL(uri); + }, + { once: true } + ); + button.appendChild(img); + button.style.width = buttonWidth + "px"; + button.setAttribute("engine-name", engine.name); + + button.engineName = engine.name; + button.addEventListener("click", this); + button.addEventListener("mousemove", this); + + if (engines.length - i <= enginesPerRow - (i % enginesPerRow)) { + button.classList.add("last-row"); + } + + if ((i + 1) % enginesPerRow == 0) { + button.classList.add("end-of-row"); + } + + button.id = ONE_OFF_ID_PREFIX + i; + cell.appendChild(button); + this._oneOffButtons.push(button); + } + row.appendChild(cell); + this._oneOffsTable.appendChild(row); + this._oneOffsTable.hidden = false; + }, + + _sendMsg(type, data = null) { + dispatchEvent( + new CustomEvent("ContentSearchClient", { + detail: { + type, + data, + }, + }) + ); + }, + }; + + return ContentSearchUIController; +})(); diff --git a/browser/components/search/content/searchbar.js b/browser/components/search/content/searchbar.js new file mode 100644 index 0000000000..986a1b4d82 --- /dev/null +++ b/browser/components/search/content/searchbar.js @@ -0,0 +1,907 @@ +/* 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/. */ + +"use strict"; + +/* globals XULCommandEvent */ + +// This is loaded into chrome windows with the subscript loader. Wrap in +// a block to prevent accidentally leaking globals onto `window`. +{ + const lazy = {}; + + ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + SearchSuggestionController: + "resource://gre/modules/SearchSuggestionController.sys.mjs", + }); + + /** + * Defines the search bar element. + */ + class MozSearchbar extends MozXULElement { + static get inheritedAttributes() { + return { + ".searchbar-textbox": + "disabled,disableautocomplete,searchengine,src,newlines", + ".searchbar-search-button": "addengines", + }; + } + + static get markup() { + return ` + <stringbundle src="chrome://browser/locale/search.properties"></stringbundle> + <hbox class="searchbar-search-button" data-l10n-id="searchbar-icon" role="button" keyNav="false" aria-expanded="false" aria-controls="PopupSearchAutoComplete" aria-haspopup="true"> + <image class="searchbar-search-icon"></image> + <image class="searchbar-search-icon-overlay"></image> + </hbox> + <html:input class="searchbar-textbox" is="autocomplete-input" type="search" data-l10n-id="searchbar-input" autocompletepopup="PopupSearchAutoComplete" autocompletesearch="search-autocomplete" autocompletesearchparam="searchbar-history" maxrows="10" completeselectedindex="true" minresultsforpopup="0"/> + <menupopup class="textbox-contextmenu"></menupopup> + <hbox class="search-go-container" align="center"> + <image class="search-go-button urlbar-icon" role="button" keyNav="false" hidden="true" onclick="handleSearchCommand(event);" data-l10n-id="searchbar-submit"></image> + </hbox> + `; + } + + constructor() { + super(); + + MozXULElement.insertFTLIfNeeded("browser/search.ftl"); + + this.destroy = this.destroy.bind(this); + this._setupEventListeners(); + let searchbar = this; + this.observer = { + observe(aEngine, aTopic, aVerb) { + if (aTopic == "browser-search-engine-modified") { + // Make sure the engine list is refetched next time it's needed + searchbar._engines = null; + + // Update the popup header and update the display after any modification. + searchbar._textbox.popup.updateHeader(); + searchbar.updateDisplay(); + } + }, + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + }; + + this._ignoreFocus = false; + this._engines = null; + this.telemetrySelectedIndex = -1; + } + + connectedCallback() { + // Don't initialize if this isn't going to be visible + if (this.closest("#BrowserToolbarPalette")) { + return; + } + + this.appendChild(this.constructor.fragment); + this.initializeAttributeInheritance(); + + // Don't go further if in Customize mode. + if (this.parentNode.parentNode.localName == "toolbarpaletteitem") { + return; + } + + // Ensure we get persisted widths back, if we've been in the palette: + let storedWidth = Services.xulStore.getValue( + document.documentURI, + this.parentNode.id, + "width" + ); + if (storedWidth) { + this.parentNode.setAttribute("width", storedWidth); + this.parentNode.style.width = storedWidth + "px"; + } + + this._stringBundle = this.querySelector("stringbundle"); + this._textbox = this.querySelector(".searchbar-textbox"); + + this._menupopup = null; + this._pasteAndSearchMenuItem = null; + + this._setupTextboxEventListeners(); + this._initTextbox(); + + window.addEventListener("unload", this.destroy); + + Services.obs.addObserver(this.observer, "browser-search-engine-modified"); + + this._initialized = true; + + (window.delayedStartupPromise || Promise.resolve()).then(() => { + window.requestIdleCallback(() => { + Services.search + .init() + .then(aStatus => { + // Bail out if the binding's been destroyed + if (!this._initialized) { + return; + } + + // Ensure the popup header is updated if the user has somehow + // managed to open the popup before the search service has finished + // initializing. + this._textbox.popup.updateHeader(); + // Refresh the display (updating icon, etc) + this.updateDisplay(); + BrowserSearch.updateOpenSearchBadge(); + }) + .catch(status => + console.error( + "Cannot initialize search service, bailing out:", + status + ) + ); + }); + }); + + // Wait until the popupshowing event to avoid forcing immediate + // attachment of the search-one-offs binding. + this.textbox.popup.addEventListener( + "popupshowing", + () => { + let oneOffButtons = this.textbox.popup.oneOffButtons; + // Some accessibility tests create their own <searchbar> that doesn't + // use the popup binding below, so null-check oneOffButtons. + if (oneOffButtons) { + oneOffButtons.telemetryOrigin = "searchbar"; + // Set .textbox first, since the popup setter will cause + // a _rebuild call that uses it. + oneOffButtons.textbox = this.textbox; + oneOffButtons.popup = this.textbox.popup; + } + }, + { capture: true, once: true } + ); + } + + async getEngines() { + if (!this._engines) { + this._engines = await Services.search.getVisibleEngines(); + } + return this._engines; + } + + set currentEngine(val) { + if (PrivateBrowsingUtils.isWindowPrivate(window)) { + Services.search.setDefaultPrivate( + val, + Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR + ); + } else { + Services.search.setDefault( + val, + Ci.nsISearchService.CHANGE_REASON_USER_SEARCHBAR + ); + } + } + + get currentEngine() { + let currentEngine; + if (PrivateBrowsingUtils.isWindowPrivate(window)) { + currentEngine = Services.search.defaultPrivateEngine; + } else { + currentEngine = Services.search.defaultEngine; + } + // Return a dummy engine if there is no currentEngine + return currentEngine || { name: "", uri: null }; + } + + /** + * textbox is used by sanitize.js to clear the undo history when + * clearing form information. + * + * @returns {HTMLInputElement} + */ + get textbox() { + return this._textbox; + } + + set value(val) { + this._textbox.value = val; + } + + get value() { + return this._textbox.value; + } + + destroy() { + if (this._initialized) { + this._initialized = false; + window.removeEventListener("unload", this.destroy); + + Services.obs.removeObserver( + this.observer, + "browser-search-engine-modified" + ); + } + + // Make sure to break the cycle from _textbox to us. Otherwise we leak + // the world. But make sure it's actually pointing to us. + // Also make sure the textbox has ever been constructed, otherwise the + // _textbox getter will cause the textbox constructor to run, add an + // observer, and leak the world too. + if ( + this._textbox && + this._textbox.mController && + this._textbox.mController.input && + this._textbox.mController.input.wrappedJSObject == + this.nsIAutocompleteInput + ) { + this._textbox.mController.input = null; + } + } + + focus() { + this._textbox.focus(); + } + + select() { + this._textbox.select(); + } + + setIcon(element, uri) { + element.setAttribute("src", uri); + } + + updateDisplay() { + this._textbox.title = this._stringBundle.getFormattedString("searchtip", [ + this.currentEngine.name, + ]); + } + + updateGoButtonVisibility() { + this.querySelector(".search-go-button").hidden = !this._textbox.value; + } + + openSuggestionsPanel(aShowOnlySettingsIfEmpty) { + if (this._textbox.open) { + return; + } + + this._textbox.showHistoryPopup(); + let searchIcon = document.querySelector(".searchbar-search-button"); + searchIcon.setAttribute("aria-expanded", "true"); + + if (this._textbox.value) { + // showHistoryPopup does a startSearch("") call, ensure the + // controller handles the text from the input box instead: + this._textbox.mController.handleText(); + } else if (aShowOnlySettingsIfEmpty) { + this.setAttribute("showonlysettings", "true"); + } + } + + async selectEngine(aEvent, isNextEngine) { + // Stop event bubbling now, because the rest of this method is async. + aEvent.preventDefault(); + aEvent.stopPropagation(); + + // Find the new index. + let engines = await this.getEngines(); + let currentName = this.currentEngine.name; + let newIndex = -1; + let lastIndex = engines.length - 1; + for (let i = lastIndex; i >= 0; --i) { + if (engines[i].name == currentName) { + // Check bounds to cycle through the list of engines continuously. + if (!isNextEngine && i == 0) { + newIndex = lastIndex; + } else if (isNextEngine && i == lastIndex) { + newIndex = 0; + } else { + newIndex = i + (isNextEngine ? 1 : -1); + } + break; + } + } + + this.currentEngine = engines[newIndex]; + + this.openSuggestionsPanel(); + } + + handleSearchCommand(aEvent, aEngine, aForceNewTab) { + let where = "current"; + let params; + const newTabPref = Services.prefs.getBoolPref("browser.search.openintab"); + + // Open ctrl/cmd clicks on one-off buttons in a new background tab. + if ( + aEvent && + aEvent.originalTarget.classList.contains("search-go-button") + ) { + if (aEvent.button == 2) { + return; + } + where = whereToOpenLink(aEvent, false, true); + if ( + newTabPref && + !aEvent.altKey && + !aEvent.getModifierState("AltGraph") && + where == "current" && + !gBrowser.selectedTab.isEmpty + ) { + where = "tab"; + } + } else if (aForceNewTab) { + where = "tab"; + if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) { + where += "-background"; + } + } else { + if ( + (KeyboardEvent.isInstance(aEvent) && + (aEvent.altKey || aEvent.getModifierState("AltGraph"))) ^ + newTabPref && + !gBrowser.selectedTab.isEmpty + ) { + where = "tab"; + } + if ( + MouseEvent.isInstance(aEvent) && + (aEvent.button == 1 || aEvent.getModifierState("Accel")) + ) { + where = "tab"; + params = { + inBackground: true, + }; + } + } + this.handleSearchCommandWhere(aEvent, aEngine, where, params); + } + + handleSearchCommandWhere(aEvent, aEngine, aWhere, aParams = {}) { + let textBox = this._textbox; + let textValue = textBox.value; + + let selectedIndex = this.telemetrySelectedIndex; + let isOneOff = false; + + BrowserSearchTelemetry.recordSearchSuggestionSelectionMethod( + aEvent, + "searchbar", + selectedIndex + ); + + if (selectedIndex == -1) { + isOneOff = + this.textbox.popup.oneOffButtons.eventTargetIsAOneOff(aEvent); + } + + if (aWhere === "tab" && !!aParams.inBackground) { + // Keep the focus in the search bar. + aParams.avoidBrowserFocus = true; + } else if ( + aWhere !== "window" && + aEvent.keyCode === KeyEvent.DOM_VK_RETURN + ) { + // Move the focus to the selected browser when keyup the Enter. + aParams.avoidBrowserFocus = true; + this._needBrowserFocusAtEnterKeyUp = true; + } + + // This is a one-off search only if oneOffRecorded is true. + this.doSearch(textValue, aWhere, aEngine, aParams, isOneOff); + } + + doSearch(aData, aWhere, aEngine, aParams, isOneOff = false) { + let textBox = this._textbox; + let engine = aEngine || this.currentEngine; + + // Save the current value in the form history + if ( + aData && + !PrivateBrowsingUtils.isWindowPrivate(window) && + lazy.FormHistory.enabled && + aData.length <= + lazy.SearchSuggestionController.SEARCH_HISTORY_MAX_VALUE_LENGTH + ) { + lazy.FormHistory.update({ + op: "bump", + fieldname: textBox.getAttribute("autocompletesearchparam"), + value: aData, + source: engine.name, + }).catch(error => + console.error("Saving search to form history failed:", error) + ); + } + + let submission = engine.getSubmission(aData, null, "searchbar"); + + // If we hit here, we come either from a one-off, a plain search or a suggestion. + const details = { + isOneOff, + isSuggestion: !isOneOff && this.telemetrySelectedIndex != -1, + }; + + this.telemetrySelectedIndex = -1; + + BrowserSearchTelemetry.recordSearch( + gBrowser.selectedBrowser, + engine, + "searchbar", + details + ); + + // Record when the user uses the search bar + Services.prefs.setStringPref( + "browser.search.widget.lastUsed", + new Date().toISOString() + ); + + // null parameter below specifies HTML response for search + let params = { + postData: submission.postData, + globalHistoryOptions: { + triggeringSearchEngine: engine.name, + }, + }; + if (aParams) { + for (let key in aParams) { + params[key] = aParams[key]; + } + } + openTrustedLinkIn(submission.uri.spec, aWhere, params); + } + + disconnectedCallback() { + this.destroy(); + while (this.firstChild) { + this.firstChild.remove(); + } + } + + /** + * Determines if we should select all the text in the searchbar based on the + * searchbar state, and whether the selection is empty. + */ + _maybeSelectAll() { + if ( + !this._preventClickSelectsAll && + document.activeElement == this._textbox && + this._textbox.selectionStart == this._textbox.selectionEnd + ) { + this.select(); + } + } + + _setupEventListeners() { + this.addEventListener("click", event => { + this._maybeSelectAll(); + }); + + this.addEventListener( + "DOMMouseScroll", + event => { + if (event.getModifierState("Accel")) { + this.selectEngine(event, event.detail > 0); + } + }, + true + ); + + this.addEventListener("input", event => { + this.updateGoButtonVisibility(); + }); + + this.addEventListener("drop", event => { + this.updateGoButtonVisibility(); + }); + + this.addEventListener( + "blur", + event => { + // Reset the flag since we can't capture enter keyup event if the event happens + // after moving the focus. + this._needBrowserFocusAtEnterKeyUp = false; + + // If the input field is still focused then a different window has + // received focus, ignore the next focus event. + this._ignoreFocus = document.activeElement == this._textbox; + }, + true + ); + + this.addEventListener( + "focus", + event => { + // Speculatively connect to the current engine's search URI (and + // suggest URI, if different) to reduce request latency + this.currentEngine.speculativeConnect({ + window, + originAttributes: gBrowser.contentPrincipal.originAttributes, + }); + + if (this._ignoreFocus) { + // This window has been re-focused, don't show the suggestions + this._ignoreFocus = false; + return; + } + + // Don't open the suggestions if there is no text in the textbox. + if (!this._textbox.value) { + return; + } + + // Don't open the suggestions if the mouse was used to focus the + // textbox, that will be taken care of in the click handler. + if ( + Services.focus.getLastFocusMethod(window) & + Services.focus.FLAG_BYMOUSE + ) { + return; + } + + this.openSuggestionsPanel(); + }, + true + ); + + this.addEventListener("mousedown", event => { + this._preventClickSelectsAll = this._textbox.focused; + // Ignore right clicks + if (event.button != 0) { + return; + } + + // Ignore clicks on the search go button. + if (event.originalTarget.classList.contains("search-go-button")) { + return; + } + + // Ignore clicks on menu items in the input's context menu. + if (event.originalTarget.localName == "menuitem") { + return; + } + + let isIconClick = event.originalTarget.classList.contains( + "searchbar-search-button" + ); + + // Hide popup when icon is clicked while popup is open + if (isIconClick && this.textbox.popup.popupOpen) { + this.textbox.popup.closePopup(); + let searchIcon = document.querySelector(".searchbar-search-button"); + searchIcon.setAttribute("aria-expanded", "false"); + } else if (isIconClick || this._textbox.value) { + // Open the suggestions whenever clicking on the search icon or if there + // is text in the textbox. + this.openSuggestionsPanel(true); + } + }); + } + + _setupTextboxEventListeners() { + this.textbox.addEventListener("input", event => { + this.textbox.popup.removeAttribute("showonlysettings"); + }); + + this.textbox.addEventListener("dragover", event => { + let types = event.dataTransfer.types; + if ( + types.includes("text/plain") || + types.includes("text/x-moz-text-internal") + ) { + event.preventDefault(); + } + }); + + this.textbox.addEventListener("drop", event => { + let dataTransfer = event.dataTransfer; + let data = dataTransfer.getData("text/plain"); + if (!data) { + data = dataTransfer.getData("text/x-moz-text-internal"); + } + if (data) { + event.preventDefault(); + this.textbox.value = data; + this.openSuggestionsPanel(); + } + }); + + this.textbox.addEventListener("contextmenu", event => { + if (!this._menupopup) { + this._buildContextMenu(); + } + + this._textbox.closePopup(); + + // Make sure the context menu isn't opened via keyboard shortcut. Check for text selection + // before updating the state of any menu items. + if (event.button) { + this._maybeSelectAll(); + } + + // Update disabled state of menu items + for (let item of this._menupopup.querySelectorAll("menuitem[cmd]")) { + let command = item.getAttribute("cmd"); + let controller = + document.commandDispatcher.getControllerForCommand(command); + item.disabled = !controller.isCommandEnabled(command); + } + + let pasteEnabled = document.commandDispatcher + .getControllerForCommand("cmd_paste") + .isCommandEnabled("cmd_paste"); + this._pasteAndSearchMenuItem.disabled = !pasteEnabled; + + this._menupopup.openPopupAtScreen(event.screenX, event.screenY, true); + + event.preventDefault(); + }); + } + + _initTextbox() { + if (this.parentNode.parentNode.localName == "toolbarpaletteitem") { + return; + } + + this.setAttribute("role", "combobox"); + this.setAttribute("aria-owns", this.textbox.popup.id); + + // This overrides the searchParam property in autocomplete.xml. We're + // hijacking this property as a vehicle for delivering the privacy + // information about the window into the guts of nsSearchSuggestions. + // Note that the setter is the same as the parent. We were not sure whether + // we can override just the getter. If that proves to be the case, the setter + // can be removed. + Object.defineProperty(this.textbox, "searchParam", { + get() { + return ( + this.getAttribute("autocompletesearchparam") + + (PrivateBrowsingUtils.isWindowPrivate(window) ? "|private" : "") + ); + }, + set(val) { + this.setAttribute("autocompletesearchparam", val); + }, + }); + + Object.defineProperty(this.textbox, "selectedButton", { + get() { + return this.popup.oneOffButtons.selectedButton; + }, + set(val) { + this.popup.oneOffButtons.selectedButton = val; + }, + }); + + // This is implemented so that when textbox.value is set directly (e.g., + // by tests), the one-off query is updated. + this.textbox.onBeforeValueSet = aValue => { + if (this.textbox.popup._oneOffButtons) { + this.textbox.popup.oneOffButtons.query = aValue; + } + return aValue; + }; + + // Returns true if the event is handled by us, false otherwise. + this.textbox.onBeforeHandleKeyDown = aEvent => { + if (aEvent.getModifierState("Accel")) { + if ( + aEvent.keyCode == KeyEvent.DOM_VK_DOWN || + aEvent.keyCode == KeyEvent.DOM_VK_UP + ) { + this.selectEngine(aEvent, aEvent.keyCode == KeyEvent.DOM_VK_DOWN); + return true; + } + return false; + } + + if ( + (AppConstants.platform == "macosx" && + aEvent.keyCode == KeyEvent.DOM_VK_F4) || + (aEvent.getModifierState("Alt") && + (aEvent.keyCode == KeyEvent.DOM_VK_DOWN || + aEvent.keyCode == KeyEvent.DOM_VK_UP)) + ) { + if (!this.textbox.openSearch()) { + aEvent.preventDefault(); + aEvent.stopPropagation(); + return true; + } + } + + let popup = this.textbox.popup; + let searchIcon = document.querySelector(".searchbar-search-button"); + searchIcon.setAttribute("aria-expanded", popup.popupOpen); + if (popup.popupOpen) { + let suggestionsHidden = + popup.richlistbox.getAttribute("collapsed") == "true"; + let numItems = suggestionsHidden ? 0 : popup.matchCount; + return popup.oneOffButtons.handleKeyDown(aEvent, numItems, true); + } else if (aEvent.keyCode == KeyEvent.DOM_VK_ESCAPE) { + if (this.textbox.editor.canUndo) { + this.textbox.editor.undoAll(); + } else { + this.textbox.select(); + } + return true; + } + return false; + }; + + // This method overrides the autocomplete binding's openPopup (essentially + // duplicating the logic from the autocomplete popup binding's + // openAutocompletePopup method), modifying it so that the popup is aligned with + // the inner textbox, but sized to not extend beyond the search bar border. + this.textbox.openPopup = () => { + // Entering customization mode after the search bar had focus causes + // the popup to appear again, due to focus returning after the + // hamburger panel closes. Don't open in that spurious event. + if (document.documentElement.getAttribute("customizing") == "true") { + return; + } + + let popup = this.textbox.popup; + let searchIcon = document.querySelector(".searchbar-search-button"); + if (!popup.mPopupOpen) { + // Initially the panel used for the searchbar (PopupSearchAutoComplete + // in browser.xhtml) is hidden to avoid impacting startup / new + // window performance. The base binding's openPopup would normally + // call the overriden openAutocompletePopup in + // browser-search-autocomplete-result-popup binding to unhide the popup, + // but since we're overriding openPopup we need to unhide the panel + // ourselves. + popup.hidden = false; + + // Don't roll up on mouse click in the anchor for the search UI. + if (popup.id == "PopupSearchAutoComplete") { + popup.setAttribute("norolluponanchor", "true"); + } + + popup.mInput = this.textbox; + // clear any previous selection, see bugs 400671 and 488357 + popup.selectedIndex = -1; + + // Ensure the panel has a meaningful initial size and doesn't grow + // unconditionally. + let { width } = window.windowUtils.getBoundsWithoutFlushing(this); + if (popup.oneOffButtons) { + // We have a min-width rule on search-panel-one-offs to show at + // least 4 buttons, so take that into account here. + width = Math.max(width, popup.oneOffButtons.buttonWidth * 4); + } + + popup.style.setProperty("--panel-width", width + "px"); + popup._invalidate(); + popup.openPopup(this, "after_start"); + searchIcon.setAttribute("aria-expanded", "true"); + } + }; + + this.textbox.openSearch = () => { + if (!this.textbox.popupOpen) { + this.openSuggestionsPanel(); + return false; + } + return true; + }; + + this.textbox.handleEnter = event => { + // Toggle the open state of the add-engine menu button if it's + // selected. We're using handleEnter for this instead of listening + // for the command event because a command event isn't fired. + if ( + this.textbox.selectedButton && + this.textbox.selectedButton.getAttribute("anonid") == + "addengine-menu-button" + ) { + this.textbox.selectedButton.open = !this.textbox.selectedButton.open; + return true; + } + // Otherwise, "call super": do what the autocomplete binding's + // handleEnter implementation does. + return this.textbox.mController.handleEnter(false, event || null); + }; + + // override |onTextEntered| in autocomplete.xml + this.textbox.onTextEntered = event => { + this.textbox.editor.clearUndoRedo(); + + let engine; + let oneOff = this.textbox.selectedButton; + if (oneOff) { + if (!oneOff.engine) { + oneOff.doCommand(); + return; + } + engine = oneOff.engine; + } + if (this.textbox.popupSelectedIndex != -1) { + this.telemetrySelectedIndex = this.textbox.popupSelectedIndex; + this.textbox.popupSelectedIndex = -1; + } + this.handleSearchCommand(event, engine); + }; + + this.textbox.onbeforeinput = event => { + if (event.data && this._needBrowserFocusAtEnterKeyUp) { + // Ignore char key input while processing enter key. + event.preventDefault(); + } + }; + + this.textbox.onkeyup = event => { + // Pressing Enter key while pressing Meta key, and next, even when + // releasing Enter key before releasing Meta key, the keyup event is not + // fired. Therefore, if Enter keydown is detecting, continue the post + // processing for Enter key when any keyup event is detected. + if (this._needBrowserFocusAtEnterKeyUp) { + this._needBrowserFocusAtEnterKeyUp = false; + gBrowser.selectedBrowser.focus(); + } + }; + } + + _buildContextMenu() { + const raw = ` + <menuitem data-l10n-id="text-action-undo" cmd="cmd_undo"/> + <menuitem data-l10n-id="text-action-redo" cmd="cmd_redo"/> + <menuseparator/> + <menuitem data-l10n-id="text-action-cut" cmd="cmd_cut"/> + <menuitem data-l10n-id="text-action-copy" cmd="cmd_copy"/> + <menuitem data-l10n-id="text-action-paste" cmd="cmd_paste"/> + <menuitem class="searchbar-paste-and-search"/> + <menuitem data-l10n-id="text-action-delete" cmd="cmd_delete"/> + <menuitem data-l10n-id="text-action-select-all" cmd="cmd_selectAll"/> + <menuseparator/> + <menuitem class="searchbar-clear-history"/> + `; + + this._menupopup = this.querySelector(".textbox-contextmenu"); + + let frag = MozXULElement.parseXULToFragment(raw); + + // Insert attributes that come from localized properties + this._pasteAndSearchMenuItem = frag.querySelector( + ".searchbar-paste-and-search" + ); + this._pasteAndSearchMenuItem.setAttribute( + "label", + this._stringBundle.getString("cmd_pasteAndSearch") + ); + + let clearHistoryItem = frag.querySelector(".searchbar-clear-history"); + clearHistoryItem.setAttribute( + "label", + this._stringBundle.getString("cmd_clearHistory") + ); + clearHistoryItem.setAttribute( + "accesskey", + this._stringBundle.getString("cmd_clearHistory_accesskey") + ); + + this._menupopup.appendChild(frag); + + this._menupopup.addEventListener("command", event => { + switch (event.originalTarget) { + case this._pasteAndSearchMenuItem: + this.select(); + goDoCommand("cmd_paste"); + this.handleSearchCommand(event); + break; + case clearHistoryItem: + let param = this.textbox.getAttribute("autocompletesearchparam"); + lazy.FormHistory.update({ op: "remove", fieldname: param }); + this.textbox.value = ""; + break; + default: + let cmd = event.originalTarget.getAttribute("cmd"); + if (cmd) { + let controller = + document.commandDispatcher.getControllerForCommand(cmd); + controller.doCommand(cmd); + } + break; + } + }); + } + } + + customElements.define("searchbar", MozSearchbar); +} diff --git a/browser/components/search/docs/Preferences.rst b/browser/components/search/docs/Preferences.rst new file mode 100644 index 0000000000..ca379ed712 --- /dev/null +++ b/browser/components/search/docs/Preferences.rst @@ -0,0 +1,25 @@ +Preferences +=========== + +This document describes preferences affecting Firefox's Search UI code. For information +on the toolkit search service, see the :doc:`/toolkit/search/Preferences` document. +Preferences that are generated and updated by code won't be described here. + +User Exposed +------------ +These preferences are exposed through the Firefox UI + +browser.search.widget.inNavBar (boolean, default: false) + Whether the search bar widget is displayed in the navigation bar. + +Hidden +------ +These preferences are normally hidden, and should not be used unless you really +know what you are doing. + +browser.search.openintab (boolean, default: false) + Whether or not items opened from the search bar are opened in a new tab. + +browser.search.context.loadInBackground (boolean, default: false) + Whether or not tabs opened from searching in the context menu are loaded in + the foreground or background. diff --git a/browser/components/search/docs/application-search-engines.rst b/browser/components/search/docs/application-search-engines.rst new file mode 100644 index 0000000000..30bc0b7575 --- /dev/null +++ b/browser/components/search/docs/application-search-engines.rst @@ -0,0 +1,41 @@ +Application Search Engines +========================== + +Firefox defines various application search engines that are shipped to users. + +The extensions for the definitions of these engines live in +:searchfox:`browser/components/search/extensions <browser/components/search/extensions>` + +Icons +----- + +Icon Requirements +~~~~~~~~~~~~~~~~~ + +It is preferred that each engine is shipped with a ``.ico`` file with two sizes +of image contained within the file: + + * 16 x 16 pixels + * 32 x 32 pixels + +Some engines also have icons in +:searchfox:`browser/components/newtab/data/content/tippytop <browser/components/newtab/data/content/tippytop>`. +For these engines, there are two sizes depending on the subdirectory: + + * ``favicons/``: 32 x 32 pixels + * ``images/``: preferred minimum of 192 x 192 pixels + +Updating Icons +~~~~~~~~~~~~~~ + +To update icons for application search engines: + + * Place the new icon file in the :searchfox:`folder associated with the search engine <browser/components/search/extensions>`. + * Increase the version number in the associated manifest file. This ensures + that the add-on manager properly updates the engine. + * Be aware that the :searchfox:`allowed-dupes.mn file <browser/installer/allowed-dupes.mn>` + lists some icons that are intended as duplicates. + +To update icons for tippytop: + + * Place the new icon file in :searchfox:`both the sub-folders within the tippytop directory <browser/components/newtab/data/content/tippytop>`. diff --git a/browser/components/search/docs/index.rst b/browser/components/search/docs/index.rst new file mode 100644 index 0000000000..a608a12876 --- /dev/null +++ b/browser/components/search/docs/index.rst @@ -0,0 +1,23 @@ +Search +====== + +This document describes the implementation of parts of Firefox's search interfaces. + +The search area covers: + + * Search bar on the toolbar + * In-content search + * One-off search buttons on both the search and address bars + +Search Engine handling is taken care of with the `toolkit Search Service`_. + +Most of the search code lives in `browser/components/search`_. + +.. toctree:: + + application-search-engines + Preferences + telemetry + +.. _toolkit Search Service: /toolkit/search/index.html +.. _browser/components/search: https://searchfox.org/mozilla-central/source/browser/components/search diff --git a/browser/components/search/docs/telemetry.rst b/browser/components/search/docs/telemetry.rst new file mode 100644 index 0000000000..bbf67aa68f --- /dev/null +++ b/browser/components/search/docs/telemetry.rst @@ -0,0 +1,201 @@ +Telemetry +========= + +This section describes existing telemetry probes measuring interaction with +search engines from the browser UI. + +Other search-related telemetry is recorded by Toolkit such as search service +telemetry and telemetry related to fetching search suggestions. Toolkit search +telemetry is relevant to Firefox as well as other consumers of Toolkit. See +:doc:`/toolkit/search/Telemetry` in the Toolkit documentation for details. + +.. contents:: + :depth: 2 + + +Glossary +-------- + +SAP + Search Access Point, a search that a user performs by visiting + via one of Firefox's access points using the associated partner codes. + +SERP + A search engine results page. + +Persisted Search + When a user has the following preference values: + + - ``browser.urlbar.showSearchTerms.enabled``: ``true`` + - ``browser.urlbar.showSearchTerms.featureGate``: ``true`` + - ``browser.search.widget.inNavBar``: ``false`` + + and does the following: + + - Starts a search from the urlbar or context menu. + - Loads the default search engine results page. + + the search term will persist in the Urlbar, causing it to enter a Persisted Search state. + +.. _serp-definitions: + +Definitions +----------- + +``organic`` + A search that a user performs by visiting a search engine directly. + +``tagged`` + Refers to a page that is tagged with an associated partner code. + It may or may not have originated via a SAP. + +``tagged-follow-on`` + Refers to a page that is tagged with an associated partner code and has been identified + as a follow-on search. It may or may not have originated via a SAP. + +Search probes relevant to front-end searches +-------------------------------------------- + +The Address Bar is an integral part of search and has `additional telemetry of its own`_. + +BrowserSearchTelemetry.sys.mjs +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This telemetry is handled by `BrowserSearchTelemetry.sys.mjs`_. + +SEARCH_COUNTS - SAP usage +^^^^^^^^^^^^^^^^^^^^^^^^^ + + This histogram tracks search engines and Search Access Points. It is augmented + by multiple SAPs, including the urlbar. + It's a keyed histogram, the keys are strings made up of search engine names + and SAP names, for example ``google.urlbar``. + For each key, this records the count of searches made using that engine and SAP. + SAP names can be: + + - ``alias`` This is when using an alias (like ``@google``) in the urlbar. + Note there is often confusion between the terms alias and keyword, and + they may be used inappropriately: aliases refer to search engines, while + keywords refer to bookmarks. We expect no results for this SAP in Firefox + 83+, since urlbar-searchmode replaces it. + - ``abouthome`` + - ``contextmenu`` + - ``newtab`` + - ``searchbar`` + - ``system`` + - ``urlbar`` Except aliases and search mode. + - ``urlbar-handoff`` Used when searching from about:newtab. + - ``urlbar-persisted`` Used when searching from the Urlbar while it + was in a Persisted Search state. + - ``urlbar-searchmode`` Used when the Urlbar is in search mode. + - ``webextension`` + +browser.engagement.navigation.* +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + These keyed scalars track search through different SAPs, for example the + urlbar is tracked by ``browser.engagement.navigation.urlbar``. + It counts loads triggered in a subsession from the specified SAP, broken down + by the originating action. + Possible SAPs are: + + - ``urlbar`` Except search mode. + - ``urlbar_handoff`` Used when searching from about:newtab. + - ``urlbar_persisted`` Used when searching from the Urlbar while it + was in a Persisted Search state. + - ``urlbar_searchmode`` Used when the Urlbar is in search mode. + - ``searchbar`` + - ``about_home`` + - ``about_newtab`` + - ``contextmenu`` + - ``webextension`` + - ``system`` Indicates a search from the command line. + + Recorded actions may be: + + - ``search`` + Used for any search from ``contextmenu``, ``system`` and ``webextension``. + - ``search_alias`` + For ``urlbar``, indicates the user confirmed a search through an alias. + - ``search_enter`` + For ``about_home`` and ``about:newtab`` this counts any search. + For the other SAPs it tracks typing and then pressing Enter. + - ``search_formhistory`` + For ``urlbar``, indicates the user picked a form history result. + - ``search_oneoff`` + For ``urlbar`` or ``searchbar``, indicates the user confirmed a search + using a one-off button. + - ``search_suggestion`` + For ``urlbar`` or ``searchbar``, indicates the user confirmed a search + suggestion. + +navigation.search (OBSOLETE) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + This is a legacy and disabled event telemetry that is currently under + discussion for removal or modernization. It can't be enabled through a pref. + it's more or less equivalent to browser.engagement.navigation, but can also + report the picked search engine. + +SearchSERPTelemetry.sys.mjs +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This telemetry is handled by `SearchSERPTelemetry.sys.mjs and the associated parent/child actors`_. + +browser.search.content.* +^^^^^^^^^^^^^^^^^^^^^^^^ + + These keyed scalars track counts of SERP page loads. + + The key format is ``<provider>:[tagged|tagged-follow-on|organic]:[<code>|other|none]``. + The values in angled brackets will be replaced by real values based on the URL of the + SERP page. The key format is built from: + + - ``<provider>`` The name of the provider. This is not linked to search engine + ids, as the search may have been generated organically. + - ``[tagged|tagged-follow-on|organic]`` The type of SERP load. See the + :ref:`definitions section above <serp-definitions>`. + - ``[<code>|other|none]`` Details of the code associated with the SERP load: + + - ``<code>`` The partner code found in the URL. This is only for partners + associated with the product. + - ``other`` The SERP load had a partner code, but it is not recognised as + an associated partner or an organic code. + - ``none`` The SERP load had no partner codes, or it was a recognised organic code, + e.g. some sites assign their own codes for searches. + + They are broken down by the originating SAP where known: + + - ``urlbar`` Except search mode. + - ``urlbar_handoff`` Used when searching from about:newtab. + - ``urlbar_persisted`` Used when searching from the Urlbar while it + was in a Persisted Search state. + - ``urlbar_searchmode`` Used when the Urlbar is in search mode. + - ``searchbar`` + - ``about_home`` + - ``about_newtab`` + - ``contextmenu`` + - ``webextension`` + - ``system`` Indicates a search from the command line. + - ``tabhistory`` Indicates a search was counted as a result of the user loading it from the tab history. + - ``reload`` Indicates a search was counted as a result of reloading the page. + - ``unknown`` Indicates the origin was unknown. + +browser.search.withads.* +^^^^^^^^^^^^^^^^^^^^^^^^ + + These keyed scalar track counts of SERP pages with adverts displayed. The key + format is ``<provider>:<tagged|organic>``. + + They are broken down by the originating SAP where known, the list of SAP + is the same as for ``browser.search.content.*``. + +browser.search.adclicks.* +^^^^^^^^^^^^^^^^^^^^^^^^^ + + This is the same as ```browser.search.withads.*`` but tracks counts for them + clicks of adverts on SERP pages. + +.. _additional telemetry of its own: /browser/urlbar/telemetry.html +.. _SearchSERPTelemetry.sys.mjs and the associated parent/child actors: https://searchfox.org/mozilla-central/search?q=&path=SearchSERPTelemetry*.sys.mjs&case=false®exp=false +.. _BrowserSearchTelemetry: https://searchfox.org/mozilla-central/source/browser/components/search/BrowserSearchTelemetry.sys.mjs diff --git a/browser/components/search/extensions/1und1/favicon.ico b/browser/components/search/extensions/1und1/favicon.ico Binary files differnew file mode 100644 index 0000000000..ac5a2bbb0a --- /dev/null +++ b/browser/components/search/extensions/1und1/favicon.ico diff --git a/browser/components/search/extensions/1und1/manifest.json b/browser/components/search/extensions/1und1/manifest.json new file mode 100644 index 0000000000..929da0f485 --- /dev/null +++ b/browser/components/search/extensions/1und1/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "1&1 Suche", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "1und1@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "1&1 Suche", + "search_url": "https://go.1und1.de/br/moz_search_web/", + "search_url_get_params": "q={searchTerms}&enc=UTF-8", + "suggest_url": "https://suggestplugin.ui-portal.de/s", + "suggest_url_get_params": "q={searchTerms}&brand=1und1&origin=br_splugin_ff_sg" + } + } +} diff --git a/browser/components/search/extensions/allegro-pl/favicon.ico b/browser/components/search/extensions/allegro-pl/favicon.ico Binary files differnew file mode 100644 index 0000000000..42b4f90149 --- /dev/null +++ b/browser/components/search/extensions/allegro-pl/favicon.ico diff --git a/browser/components/search/extensions/allegro-pl/manifest.json b/browser/components/search/extensions/allegro-pl/manifest.json new file mode 100644 index 0000000000..845c2d8fef --- /dev/null +++ b/browser/components/search/extensions/allegro-pl/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Allegro", + "description": "Wyszukiwanie w aukcjach Allegro", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "allegro-pl@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Allegro", + "search_url": "https://allegro.pl/listing", + "search_form": "https://allegro.pl", + "search_url_get_params": "string={searchTerms}&sourceid=Mozilla-search" + } + } +} diff --git a/browser/components/search/extensions/amazon/_locales/au/messages.json b/browser/components/search/extensions/amazon/_locales/au/messages.json new file mode 100644 index 0000000000..7cf70bf205 --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/au/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.com.au" + }, + "extensionDescription": { + "message": "Amazon.com.au Search" + }, + "searchUrl": { + "message": "https://www.amazon.com.au/s" + }, + "searchForm": { + "message": "https://www.amazon.com.au/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/ca/messages.json b/browser/components/search/extensions/amazon/_locales/ca/messages.json new file mode 100644 index 0000000000..f0ba8196e9 --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/ca/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.ca" + }, + "extensionDescription": { + "message": "Amazon.ca Search" + }, + "searchUrl": { + "message": "https://www.amazon.ca/s" + }, + "searchForm": { + "message": "https://www.amazon.ca/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/de/messages.json b/browser/components/search/extensions/amazon/_locales/de/messages.json new file mode 100644 index 0000000000..02eb9be343 --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/de/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.de" + }, + "extensionDescription": { + "message": "Amazon.de Suche" + }, + "searchUrl": { + "message": "https://www.amazon.de/s" + }, + "searchForm": { + "message": "https://www.amazon.de/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/en-GB/messages.json b/browser/components/search/extensions/amazon/_locales/en-GB/messages.json new file mode 100644 index 0000000000..63660cad49 --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/en-GB/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.co.uk" + }, + "extensionDescription": { + "message": "Amazon.co.uk Search" + }, + "searchUrl": { + "message": "https://www.amazon.co.uk/s" + }, + "searchForm": { + "message": "https://www.amazon.co.uk/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/france/messages.json b/browser/components/search/extensions/amazon/_locales/france/messages.json new file mode 100644 index 0000000000..1ca4538e1a --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/france/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.fr" + }, + "extensionDescription": { + "message": "Recherche Amazon.fr" + }, + "searchUrl": { + "message": "https://www.amazon.fr/s" + }, + "searchForm": { + "message": "https://www.amazon.fr/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/in/messages.json b/browser/components/search/extensions/amazon/_locales/in/messages.json new file mode 100644 index 0000000000..dd15ba465b --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/in/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.in" + }, + "extensionDescription": { + "message": "Amazon.in Search" + }, + "searchUrl": { + "message": "https://www.amazon.in/s" + }, + "searchForm": { + "message": "https://www.amazon.in/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/it/messages.json b/browser/components/search/extensions/amazon/_locales/it/messages.json new file mode 100644 index 0000000000..af209a5682 --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/it/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.it" + }, + "extensionDescription": { + "message": "Ricerca Amazon.it" + }, + "searchUrl": { + "message": "https://www.amazon.it/s" + }, + "searchForm": { + "message": "https://www.amazon.it/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/jp/messages.json b/browser/components/search/extensions/amazon/_locales/jp/messages.json new file mode 100644 index 0000000000..f8b43951ff --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/jp/messages.json @@ -0,0 +1,23 @@ +{ + "extensionName": { + "message": "Amazon.co.jp" + }, + "extensionDescription": { + "message": "Amazon.co.jp Search" + }, + "searchUrl": { + "message": "https://www.amazon.co.jp/exec/obidos/external-search/" + }, + "searchForm": { + "message": "https://www.amazon.co.jp/" + }, + "searchUrlGetParams": { + "message": "field-keywords={searchTerms}&mode=blended&tag=mozillajapan-fx-22&sourceid=Mozilla-search" + }, + "suggestUrl": { + "message": "https://completion.amazon.co.jp/search/complete" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&search-alias=aps&mkt=6" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/nl/messages.json b/browser/components/search/extensions/amazon/_locales/nl/messages.json new file mode 100644 index 0000000000..7781999495 --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/nl/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.nl" + }, + "extensionDescription": { + "message": "Amazon.nl Search" + }, + "searchUrl": { + "message": "https://www.amazon.nl/s" + }, + "searchForm": { + "message": "https://www.amazon.nl/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/spain/messages.json b/browser/components/search/extensions/amazon/_locales/spain/messages.json new file mode 100644 index 0000000000..25c46cc57b --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/spain/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.es" + }, + "extensionDescription": { + "message": "Amazon.es" + }, + "searchUrl": { + "message": "https://www.amazon.es/s" + }, + "searchForm": { + "message": "https://www.amazon.es/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/_locales/sweden/messages.json b/browser/components/search/extensions/amazon/_locales/sweden/messages.json new file mode 100644 index 0000000000..3fedc182c4 --- /dev/null +++ b/browser/components/search/extensions/amazon/_locales/sweden/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.se" + }, + "extensionDescription": { + "message": "Amazon.se" + }, + "searchUrl": { + "message": "https://www.amazon.se/s" + }, + "searchForm": { + "message": "https://www.amazon.se/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazon/favicon.ico b/browser/components/search/extensions/amazon/favicon.ico Binary files differnew file mode 100644 index 0000000000..1c39eaf8fe --- /dev/null +++ b/browser/components/search/extensions/amazon/favicon.ico diff --git a/browser/components/search/extensions/amazon/manifest.json b/browser/components/search/extensions/amazon/manifest.json new file mode 100644 index 0000000000..bb94be6fd0 --- /dev/null +++ b/browser/components/search/extensions/amazon/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.13", + "browser_specific_settings": { + "gecko": { + "id": "amazon@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "au", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@amazon", + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/amazondotcn/_locales/default/messages.json b/browser/components/search/extensions/amazondotcn/_locales/default/messages.json new file mode 100644 index 0000000000..79f3ce3f9b --- /dev/null +++ b/browser/components/search/extensions/amazondotcn/_locales/default/messages.json @@ -0,0 +1,8 @@ +{ + "searchUrl": { + "message": "https://www.amazon.cn/s" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazondotcn/_locales/mozillaonline/messages.json b/browser/components/search/extensions/amazondotcn/_locales/mozillaonline/messages.json new file mode 100644 index 0000000000..79f3ce3f9b --- /dev/null +++ b/browser/components/search/extensions/amazondotcn/_locales/mozillaonline/messages.json @@ -0,0 +1,8 @@ +{ + "searchUrl": { + "message": "https://www.amazon.cn/s" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazondotcn/favicon.ico b/browser/components/search/extensions/amazondotcn/favicon.ico Binary files differnew file mode 100644 index 0000000000..1c39eaf8fe --- /dev/null +++ b/browser/components/search/extensions/amazondotcn/favicon.ico diff --git a/browser/components/search/extensions/amazondotcn/manifest.json b/browser/components/search/extensions/amazondotcn/manifest.json new file mode 100644 index 0000000000..899a4a0cb0 --- /dev/null +++ b/browser/components/search/extensions/amazondotcn/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "亚马逊", + "description": "亚马逊搜索", + "manifest_version": 2, + "version": "1.3", + "browser_specific_settings": { + "gecko": { + "id": "amazondotcn@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "default", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@amazon", + "name": "亚马逊", + "search_url": "__MSG_searchUrl__", + "search_form": "https://www.amazon.cn/", + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/amazondotcom/_locales/en/messages.json b/browser/components/search/extensions/amazondotcom/_locales/en/messages.json new file mode 100644 index 0000000000..ffb0721a30 --- /dev/null +++ b/browser/components/search/extensions/amazondotcom/_locales/en/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.com" + }, + "extensionDescription": { + "message": "Amazon.com Search" + }, + "searchUrl": { + "message": "https://www.amazon.com/s" + }, + "searchForm": { + "message": "https://www.amazon.com/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazondotcom/_locales/us/messages.json b/browser/components/search/extensions/amazondotcom/_locales/us/messages.json new file mode 100644 index 0000000000..ffb0721a30 --- /dev/null +++ b/browser/components/search/extensions/amazondotcom/_locales/us/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "Amazon.com" + }, + "extensionDescription": { + "message": "Amazon.com Search" + }, + "searchUrl": { + "message": "https://www.amazon.com/s" + }, + "searchForm": { + "message": "https://www.amazon.com/" + }, + "searchUrlGetParams": { + "message": "k={searchTerms}" + } +} diff --git a/browser/components/search/extensions/amazondotcom/favicon.ico b/browser/components/search/extensions/amazondotcom/favicon.ico Binary files differnew file mode 100644 index 0000000000..1c39eaf8fe --- /dev/null +++ b/browser/components/search/extensions/amazondotcom/favicon.ico diff --git a/browser/components/search/extensions/amazondotcom/manifest.json b/browser/components/search/extensions/amazondotcom/manifest.json new file mode 100644 index 0000000000..3d186412e5 --- /dev/null +++ b/browser/components/search/extensions/amazondotcom/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.7", + "browser_specific_settings": { + "gecko": { + "id": "amazondotcom@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "en", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@amazon", + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/azerdict/favicon.ico b/browser/components/search/extensions/azerdict/favicon.ico Binary files differnew file mode 100644 index 0000000000..ba687ca8e7 --- /dev/null +++ b/browser/components/search/extensions/azerdict/favicon.ico diff --git a/browser/components/search/extensions/azerdict/manifest.json b/browser/components/search/extensions/azerdict/manifest.json new file mode 100644 index 0000000000..33d8856f7f --- /dev/null +++ b/browser/components/search/extensions/azerdict/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Azerdict", + "description": "Azərbaycanın Online Lüğəti", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "azerdict@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Azerdict", + "search_url": "https://azerdict.com/english/", + "search_form": "https://azerdict.com/", + "search_url_get_params": "word={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/baidu/favicon.ico b/browser/components/search/extensions/baidu/favicon.ico Binary files differnew file mode 100644 index 0000000000..e1c770cc4b --- /dev/null +++ b/browser/components/search/extensions/baidu/favicon.ico diff --git a/browser/components/search/extensions/baidu/manifest.json b/browser/components/search/extensions/baidu/manifest.json new file mode 100644 index 0000000000..214c5ad0cf --- /dev/null +++ b/browser/components/search/extensions/baidu/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "百度", + "description": "百度网页搜索", + "manifest_version": 2, + "version": "1.3", + "browser_specific_settings": { + "gecko": { + "id": "baidu@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": ["@\u767E\u5EA6", "@baidu"], + "name": "百度", + "search_url": "https://www.baidu.com/baidu", + "search_form": "https://www.baidu.com/", + "search_url_get_params": "tn=monline_7_dg&ie=utf-8&wd={searchTerms}", + "suggest_url": "https://www.baidu.com/su", + "suggest_url_get_params": "tn=monline_7_dg&ie=utf-8&action=opensearch&wd={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/bing/favicon.ico b/browser/components/search/extensions/bing/favicon.ico Binary files differnew file mode 100644 index 0000000000..fdc021cfeb --- /dev/null +++ b/browser/components/search/extensions/bing/favicon.ico diff --git a/browser/components/search/extensions/bing/manifest.json b/browser/components/search/extensions/bing/manifest.json new file mode 100644 index 0000000000..fdb26f13be --- /dev/null +++ b/browser/components/search/extensions/bing/manifest.json @@ -0,0 +1,59 @@ +{ + "name": "Bing", + "description": "Microsoft Bing", + "manifest_version": 2, + "version": "1.6", + "browser_specific_settings": { + "gecko": { + "id": "bing@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@bing", + "name": "Bing", + "search_url": "https://www.bing.com/search", + "search_form": "https://www.bing.com/search", + "search_url_get_params": "pc=MOZI&q={searchTerms}", + "params": [ + { + "name": "form", + "condition": "purpose", + "purpose": "contextmenu", + "value": "MOZCON" + }, + { + "name": "form", + "condition": "purpose", + "purpose": "searchbar", + "value": "MOZSBR" + }, + { + "name": "form", + "condition": "purpose", + "purpose": "homepage", + "value": "MOZSPG" + }, + { + "name": "form", + "condition": "purpose", + "purpose": "keyword", + "value": "MOZLBR" + }, + { + "name": "form", + "condition": "purpose", + "purpose": "newtab", + "value": "MOZTSB" + } + ], + "suggest_url": "https://www.bing.com/osjson.aspx", + "suggest_url_get_params": "query={searchTerms}&form=OSDJAS" + } + } +} diff --git a/browser/components/search/extensions/bok-NO/favicon.png b/browser/components/search/extensions/bok-NO/favicon.png Binary files differnew file mode 100644 index 0000000000..c2d46117ef --- /dev/null +++ b/browser/components/search/extensions/bok-NO/favicon.png diff --git a/browser/components/search/extensions/bok-NO/manifest.json b/browser/components/search/extensions/bok-NO/manifest.json new file mode 100644 index 0000000000..55b5f058a0 --- /dev/null +++ b/browser/components/search/extensions/bok-NO/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Ordbok", + "description": "Norske ordbøker", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "bok-NO@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "web_accessible_resources": ["favicon.png"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Ordbok", + "search_url": "https://ordbok.uib.no/perl/ordbok.cgi", + "search_form": "https://ordbok.uib.no/", + "search_url_get_params": "OPP={searchTerms}&sourceid=Mozilla-search" + } + } +} diff --git a/browser/components/search/extensions/ceneji/favicon.png b/browser/components/search/extensions/ceneji/favicon.png Binary files differnew file mode 100644 index 0000000000..3c77b64d3c --- /dev/null +++ b/browser/components/search/extensions/ceneji/favicon.png diff --git a/browser/components/search/extensions/ceneji/manifest.json b/browser/components/search/extensions/ceneji/manifest.json new file mode 100644 index 0000000000..df15149ef3 --- /dev/null +++ b/browser/components/search/extensions/ceneji/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Ceneje.si", + "description": "Iskalnik Ceneje.si", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "ceneji@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "web_accessible_resources": ["favicon.png"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Ceneje.si", + "search_url": "https://www.ceneje.si/search_new.aspx", + "search_form": "https://www.ceneje.si", + "search_url_get_params": "q={searchTerms}&FF-SearchBox=1" + } + } +} diff --git a/browser/components/search/extensions/coccoc/favicon.ico b/browser/components/search/extensions/coccoc/favicon.ico Binary files differnew file mode 100644 index 0000000000..f128244fed --- /dev/null +++ b/browser/components/search/extensions/coccoc/favicon.ico diff --git a/browser/components/search/extensions/coccoc/manifest.json b/browser/components/search/extensions/coccoc/manifest.json new file mode 100644 index 0000000000..ba44adb2ce --- /dev/null +++ b/browser/components/search/extensions/coccoc/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Cốc Cốc", + "description": "Use Cốc Cốc to search on coccoc.com", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "coccoc@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Cốc Cốc", + "search_url": "https://coccoc.com/search", + "search_url_get_params": "query={searchTerms}&s=ff&utm_source=firefox", + "suggest_url": "https://coccoc.com/composer/autocomplete", + "suggest_url_get_params": "of=b&q={searchTerms}&s=ff" + } + } +} diff --git a/browser/components/search/extensions/daum-kr/favicon.ico b/browser/components/search/extensions/daum-kr/favicon.ico Binary files differnew file mode 100644 index 0000000000..ed803f50e2 --- /dev/null +++ b/browser/components/search/extensions/daum-kr/favicon.ico diff --git a/browser/components/search/extensions/daum-kr/manifest.json b/browser/components/search/extensions/daum-kr/manifest.json new file mode 100644 index 0000000000..1e6015ea4f --- /dev/null +++ b/browser/components/search/extensions/daum-kr/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "다음", + "description": "다음 검색", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "daum-kr@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "다음", + "search_url": "https://search.daum.net/search", + "search_form": "https://search.daum.net", + "search_url_get_params": "q={searchTerms}&w=tot&nil_ch=ffsr", + "suggest_url": "https://suggest.search.daum.net/sushi/opensearch/pc", + "suggest_url_get_params": "q={searchTerms}&DA=JU2" + } + } +} diff --git a/browser/components/search/extensions/ddg/favicon.ico b/browser/components/search/extensions/ddg/favicon.ico Binary files differnew file mode 100644 index 0000000000..3ad20825c1 --- /dev/null +++ b/browser/components/search/extensions/ddg/favicon.ico diff --git a/browser/components/search/extensions/ddg/manifest.json b/browser/components/search/extensions/ddg/manifest.json new file mode 100644 index 0000000000..1e1bffed47 --- /dev/null +++ b/browser/components/search/extensions/ddg/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "DuckDuckGo", + "description": "Search DuckDuckGo", + "manifest_version": 2, + "version": "1.4", + "browser_specific_settings": { + "gecko": { + "id": "ddg@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": ["@duckduckgo", "@ddg"], + "name": "DuckDuckGo", + "search_url": "https://duckduckgo.com/", + "search_form": "https://duckduckgo.com/", + "search_url_get_params": "t=ffab&q={searchTerms}", + "suggest_url": "https://ac.duckduckgo.com/ac/", + "suggest_url_get_params": "q={searchTerms}&type=list" + } + } +} diff --git a/browser/components/search/extensions/ebay/_locales/at/messages.json b/browser/components/search/extensions/ebay/_locales/at/messages.json new file mode 100644 index 0000000000..ee7ab962cc --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/at/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.at/sch/" + }, + "searchForm": { + "message": "https://www.ebay.at/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=5221-53469-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=16&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/au/messages.json b/browser/components/search/extensions/ebay/_locales/au/messages.json new file mode 100644 index 0000000000..4e0a1d374e --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/au/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.com.au/sch/" + }, + "searchForm": { + "message": "https://www.ebay.com.au/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=705-53470-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=15&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/be/messages.json b/browser/components/search/extensions/ebay/_locales/be/messages.json new file mode 100644 index 0000000000..918c0443a9 --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/be/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.befr.ebay.be/sch/" + }, + "searchForm": { + "message": "https://www.befr.ebay.be/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=1553-53471-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=23&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/ca/messages.json b/browser/components/search/extensions/ebay/_locales/ca/messages.json new file mode 100644 index 0000000000..23b07f14b6 --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/ca/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.ca/sch/" + }, + "searchForm": { + "message": "https://www.ebay.ca/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=706-53473-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=2&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/ch/messages.json b/browser/components/search/extensions/ebay/_locales/ch/messages.json new file mode 100644 index 0000000000..2c181eaa9f --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/ch/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.ch/sch/" + }, + "searchForm": { + "message": "https://www.ebay.ch/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=5222-53480-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=193&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/de/messages.json b/browser/components/search/extensions/ebay/_locales/de/messages.json new file mode 100644 index 0000000000..02ffb6f43a --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/de/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.de/sch/" + }, + "searchForm": { + "message": "https://www.ebay.de/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=707-53477-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=77&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/en/messages.json b/browser/components/search/extensions/ebay/_locales/en/messages.json new file mode 100644 index 0000000000..d17d77f2b3 --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/en/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.com/sch/" + }, + "searchForm": { + "message": "https://www.ebay.com/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=711-53200-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=0&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/es/messages.json b/browser/components/search/extensions/ebay/_locales/es/messages.json new file mode 100644 index 0000000000..8ef67142dd --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/es/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.es/sch/" + }, + "searchForm": { + "message": "https://www.ebay.es/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=1185-53479-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=186&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/fr/messages.json b/browser/components/search/extensions/ebay/_locales/fr/messages.json new file mode 100644 index 0000000000..545afafe09 --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/fr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.fr/sch/" + }, + "searchForm": { + "message": "https://www.ebay.fr/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=709-53476-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=71&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/ie/messages.json b/browser/components/search/extensions/ebay/_locales/ie/messages.json new file mode 100644 index 0000000000..194f54adcb --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/ie/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.ie/sch/" + }, + "searchForm": { + "message": "https://www.ebay.ie/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=5282-53468-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=205&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/it/messages.json b/browser/components/search/extensions/ebay/_locales/it/messages.json new file mode 100644 index 0000000000..ff78adaa74 --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/it/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.it/sch/" + }, + "searchForm": { + "message": "https://www.ebay.it/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=724-53478-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=101&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/nl/messages.json b/browser/components/search/extensions/ebay/_locales/nl/messages.json new file mode 100644 index 0000000000..075abe659a --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/nl/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.nl/sch/" + }, + "searchForm": { + "message": "https://www.ebay.nl/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=1346-53482-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=146&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/_locales/uk/messages.json b/browser/components/search/extensions/ebay/_locales/uk/messages.json new file mode 100644 index 0000000000..d0918a1e35 --- /dev/null +++ b/browser/components/search/extensions/ebay/_locales/uk/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "eBay" + }, + "extensionDescription": { + "message": "eBay - Online auctions" + }, + "searchUrl": { + "message": "https://www.ebay.co.uk/sch/" + }, + "searchForm": { + "message": "https://www.ebay.co.uk/" + }, + "searchUrlGetParams": { + "message": "toolid=20004&campid=5338192028&mkevt=1&mkcid=1&mkrid=710-53481-19255-0&kw={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "sId=3&fmt=osr&kwd={searchTerms}" + } +} diff --git a/browser/components/search/extensions/ebay/favicon.ico b/browser/components/search/extensions/ebay/favicon.ico Binary files differnew file mode 100644 index 0000000000..3af7a36484 --- /dev/null +++ b/browser/components/search/extensions/ebay/favicon.ico diff --git a/browser/components/search/extensions/ebay/manifest.json b/browser/components/search/extensions/ebay/manifest.json new file mode 100644 index 0000000000..d4721688fe --- /dev/null +++ b/browser/components/search/extensions/ebay/manifest.json @@ -0,0 +1,28 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.4", + "browser_specific_settings": { + "gecko": { + "id": "ebay@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "en", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@ebay", + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "search_url_get_params": "__MSG_searchUrlGetParams__", + "suggest_url": "https://autosug.ebay.com/autosug", + "suggest_url_get_params": "__MSG_suggestUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/ecosia/favicon.ico b/browser/components/search/extensions/ecosia/favicon.ico Binary files differnew file mode 100644 index 0000000000..cc72d09d6d --- /dev/null +++ b/browser/components/search/extensions/ecosia/favicon.ico diff --git a/browser/components/search/extensions/ecosia/manifest.json b/browser/components/search/extensions/ecosia/manifest.json new file mode 100644 index 0000000000..74fc9aff59 --- /dev/null +++ b/browser/components/search/extensions/ecosia/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Ecosia", + "description": "Search Ecosia", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "ecosia@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Ecosia", + "search_url": "https://www.ecosia.org/search", + "search_form": "https://www.ecosia.org/", + "search_url_get_params": "tt=mzl&q={searchTerms}", + "suggest_url": "https://ac.ecosia.org/autocomplete", + "suggest_url_get_params": "type=list&q={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/eudict/favicon.ico b/browser/components/search/extensions/eudict/favicon.ico Binary files differnew file mode 100644 index 0000000000..20750d0c19 --- /dev/null +++ b/browser/components/search/extensions/eudict/favicon.ico diff --git a/browser/components/search/extensions/eudict/manifest.json b/browser/components/search/extensions/eudict/manifest.json new file mode 100644 index 0000000000..d3ebc2d77b --- /dev/null +++ b/browser/components/search/extensions/eudict/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "EUdict Eng->Cro", + "description": "EUdict - englesko-hrvatski rječnik", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "eudict@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "EUdict Eng->Cro", + "search_url": "https://eudict.com", + "search_form": "https://eudict.com?lang=engcro", + "search_url_get_params": "lang=engcro&word={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/faclair-beag/favicon.ico b/browser/components/search/extensions/faclair-beag/favicon.ico Binary files differnew file mode 100644 index 0000000000..990cf93298 --- /dev/null +++ b/browser/components/search/extensions/faclair-beag/favicon.ico diff --git a/browser/components/search/extensions/faclair-beag/manifest.json b/browser/components/search/extensions/faclair-beag/manifest.json new file mode 100644 index 0000000000..cc76816056 --- /dev/null +++ b/browser/components/search/extensions/faclair-beag/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Am Faclair Beag", + "description": "Lorg Am Faclair Beag", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "faclair-beag@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Am Faclair Beag", + "search_url": "https://www.faclair.com/", + "search_url_get_params": "txtSearch={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/gmx/_locales/de/messages.json b/browser/components/search/extensions/gmx/_locales/de/messages.json new file mode 100644 index 0000000000..d03ed6fd64 --- /dev/null +++ b/browser/components/search/extensions/gmx/_locales/de/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "GMX Suche" + }, + "searchUrl": { + "message": "https://go.gmx.net/br/moz_search_web/" + }, + "suggestUrl": { + "message": "https://suggestplugin.ui-portal.de/s" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&enc=UTF-8" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&brand=gmx&origin=br_splugin_ff_sg" + } +} diff --git a/browser/components/search/extensions/gmx/_locales/en-GB/messages.json b/browser/components/search/extensions/gmx/_locales/en-GB/messages.json new file mode 100644 index 0000000000..9822021f24 --- /dev/null +++ b/browser/components/search/extensions/gmx/_locales/en-GB/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "GMX Search" + }, + "searchUrl": { + "message": "https://go.gmx.co.uk/br/moz_search_web/" + }, + "suggestUrl": { + "message": "https://suggestplugin.gmx.co.uk/s" + }, + "searchUrlGetParams": { + "message": "enc=UTF-8&q={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&brand=gmxcouk&origin=moz_splugin_ff&enc=UTF-8" + } +} diff --git a/browser/components/search/extensions/gmx/_locales/es/messages.json b/browser/components/search/extensions/gmx/_locales/es/messages.json new file mode 100644 index 0000000000..664f36c0a1 --- /dev/null +++ b/browser/components/search/extensions/gmx/_locales/es/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "GMX - Búsqueda web" + }, + "searchUrl": { + "message": "https://go.gmx.es/br/moz_search_web/" + }, + "suggestUrl": { + "message": "https://suggestplugin.gmx.es/s" + }, + "searchUrlGetParams": { + "message": "enc=UTF-8&q={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&brand=gmxes&origin=moz_splugin_ff&enc=UTF-8" + } +} diff --git a/browser/components/search/extensions/gmx/_locales/fr/messages.json b/browser/components/search/extensions/gmx/_locales/fr/messages.json new file mode 100644 index 0000000000..2623548b99 --- /dev/null +++ b/browser/components/search/extensions/gmx/_locales/fr/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "GMX - Recherche web" + }, + "searchUrl": { + "message": "https://go.gmx.fr/br/moz_search_web/" + }, + "suggestUrl": { + "message": "https://suggestplugin.gmx.fr/s" + }, + "searchUrlGetParams": { + "message": "enc=UTF-8&q={searchTerms}" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&brand=gmxfr&origin=moz_splugin_ff&enc=UTF-8" + } +} diff --git a/browser/components/search/extensions/gmx/_locales/shopping/messages.json b/browser/components/search/extensions/gmx/_locales/shopping/messages.json new file mode 100644 index 0000000000..fa15088706 --- /dev/null +++ b/browser/components/search/extensions/gmx/_locales/shopping/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "GMX Shopping" + }, + "searchUrl": { + "message": "https://shopping.gmx.net/" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&origin=br_osd" + }, + "suggestUrl": { + "message": "https://shopping.gmx.net/suggest/ca/" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}" + } +} diff --git a/browser/components/search/extensions/gmx/favicon.png b/browser/components/search/extensions/gmx/favicon.png Binary files differnew file mode 100644 index 0000000000..020006b5e4 --- /dev/null +++ b/browser/components/search/extensions/gmx/favicon.png diff --git a/browser/components/search/extensions/gmx/manifest.json b/browser/components/search/extensions/gmx/manifest.json new file mode 100644 index 0000000000..4605b5daad --- /dev/null +++ b/browser/components/search/extensions/gmx/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionName__", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "gmx@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "de", + "icons": { + "16": "favicon.png" + }, + "web_accessible_resources": ["favicon.png"], + "chrome_settings_overrides": { + "search_provider": { + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "suggest_url": "__MSG_suggestUrl__", + "search_url_get_params": "__MSG_searchUrlGetParams__", + "suggest_url_get_params": "__MSG_suggestUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/google/_locales/en/messages.json b/browser/components/search/extensions/google/_locales/en/messages.json new file mode 100644 index 0000000000..e45a67a13f --- /dev/null +++ b/browser/components/search/extensions/google/_locales/en/messages.json @@ -0,0 +1,23 @@ +{ + "extensionName": { + "message": "Google" + }, + "extensionDescription": { + "message": "Google Search" + }, + "searchUrl": { + "message": "https://www.google.com/search" + }, + "searchForm": { + "message": "https://www.google.com/search" + }, + "suggestUrl": { + "message": "https://www.google.com/complete/search?client=firefox&q={searchTerms}" + }, + "searchUrlGetParams": { + "message": "client=firefox-b-d&q={searchTerms}" + }, + "channelPref": { + "message": "google_channel_row" + } +} diff --git a/browser/components/search/extensions/google/_locales/region-by/messages.json b/browser/components/search/extensions/google/_locales/region-by/messages.json new file mode 100644 index 0000000000..4ad45a4aba --- /dev/null +++ b/browser/components/search/extensions/google/_locales/region-by/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Google" + }, + "extensionDescription": { + "message": "Google Search" + }, + "searchUrl": { + "message": "https://www.google.by/search" + }, + "searchForm": { + "message": "https://www.google.by/search" + }, + "suggestUrl": { + "message": "https://www.google.by/complete/search?client=firefox&q={searchTerms}" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}" + } +} diff --git a/browser/components/search/extensions/google/_locales/region-kz/messages.json b/browser/components/search/extensions/google/_locales/region-kz/messages.json new file mode 100644 index 0000000000..6497b5a84a --- /dev/null +++ b/browser/components/search/extensions/google/_locales/region-kz/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Google" + }, + "extensionDescription": { + "message": "Google Search" + }, + "searchUrl": { + "message": "https://www.google.kz/search" + }, + "searchForm": { + "message": "https://www.google.kz/search" + }, + "suggestUrl": { + "message": "https://www.google.kz/complete/search?client=firefox&q={searchTerms}" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}" + } +} diff --git a/browser/components/search/extensions/google/_locales/region-ru/messages.json b/browser/components/search/extensions/google/_locales/region-ru/messages.json new file mode 100644 index 0000000000..85a6c29902 --- /dev/null +++ b/browser/components/search/extensions/google/_locales/region-ru/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Google" + }, + "extensionDescription": { + "message": "Google Search" + }, + "searchUrl": { + "message": "https://www.google.ru/search" + }, + "searchForm": { + "message": "https://www.google.ru/search" + }, + "suggestUrl": { + "message": "https://www.google.ru/complete/search?client=firefox&q={searchTerms}" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}" + } +} diff --git a/browser/components/search/extensions/google/_locales/region-tr/messages.json b/browser/components/search/extensions/google/_locales/region-tr/messages.json new file mode 100644 index 0000000000..4d8cd5a199 --- /dev/null +++ b/browser/components/search/extensions/google/_locales/region-tr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Google" + }, + "extensionDescription": { + "message": "Google Search" + }, + "searchUrl": { + "message": "https://www.google.com.tr/search" + }, + "searchForm": { + "message": "https://www.google.com.tr/search" + }, + "suggestUrl": { + "message": "https://www.google.com.tr/complete/search?client=firefox&q={searchTerms}" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}" + } +} diff --git a/browser/components/search/extensions/google/favicon.ico b/browser/components/search/extensions/google/favicon.ico Binary files differnew file mode 100644 index 0000000000..82339b3b1d --- /dev/null +++ b/browser/components/search/extensions/google/favicon.ico diff --git a/browser/components/search/extensions/google/manifest.json b/browser/components/search/extensions/google/manifest.json new file mode 100644 index 0000000000..5c48bf553f --- /dev/null +++ b/browser/components/search/extensions/google/manifest.json @@ -0,0 +1,34 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.4", + "browser_specific_settings": { + "gecko": { + "id": "google@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "en", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@google", + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "suggest_url": "__MSG_suggestUrl__", + "params": [ + { + "name": "channel", + "condition": "pref", + "pref": "__MSG_channelPref__" + } + ], + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/gulesider-NO/favicon.ico b/browser/components/search/extensions/gulesider-NO/favicon.ico Binary files differnew file mode 100644 index 0000000000..e35572a557 --- /dev/null +++ b/browser/components/search/extensions/gulesider-NO/favicon.ico diff --git a/browser/components/search/extensions/gulesider-NO/manifest.json b/browser/components/search/extensions/gulesider-NO/manifest.json new file mode 100644 index 0000000000..c01fef7989 --- /dev/null +++ b/browser/components/search/extensions/gulesider-NO/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Gule sider", + "description": "Gule sider person og firmasøk", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "gulesider-NO@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Gule sider", + "search_url": "https://www.gulesider.no/search", + "search_form": "https://www.gulesider.no/", + "search_url_get_params": "what=all&cmpid=fre_partner_fire_gssbtop&q={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/leo_ende_de/favicon.png b/browser/components/search/extensions/leo_ende_de/favicon.png Binary files differnew file mode 100644 index 0000000000..04e5e344ef --- /dev/null +++ b/browser/components/search/extensions/leo_ende_de/favicon.png diff --git a/browser/components/search/extensions/leo_ende_de/manifest.json b/browser/components/search/extensions/leo_ende_de/manifest.json new file mode 100644 index 0000000000..f6733b7dd1 --- /dev/null +++ b/browser/components/search/extensions/leo_ende_de/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "LEO Eng-Deu", + "description": "Deutsch-Englisch Wörterbuch von LEO", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "leo_ende_de@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "web_accessible_resources": ["favicon.png"], + "chrome_settings_overrides": { + "search_provider": { + "name": "LEO Eng-Deu", + "search_url": "https://dict.leo.org/englisch-deutsch/{searchTerms}", + "search_form": "https://dict.leo.org", + "suggest_url": "https://dict.leo.org/dictQuery/m-query/conf/ende/query.conf/strlist.json", + "suggest_url_get_params": "q={searchTerms}&sort=PLa&shortQuery=undefined&noDescription=undefined&noQueryURLs=undefined" + } + } +} diff --git a/browser/components/search/extensions/longdo/favicon.ico b/browser/components/search/extensions/longdo/favicon.ico Binary files differnew file mode 100644 index 0000000000..aa42cda97f --- /dev/null +++ b/browser/components/search/extensions/longdo/favicon.ico diff --git a/browser/components/search/extensions/longdo/manifest.json b/browser/components/search/extensions/longdo/manifest.json new file mode 100644 index 0000000000..51f56f7eba --- /dev/null +++ b/browser/components/search/extensions/longdo/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "พจนานุกรม ลองดู", + "description": "พจนานุกรม ลองดู", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "longdo@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "พจนานุกรม ลองดู", + "search_url": "https://dict.longdo.org/", + "search_form": "https://dict.longdo.org/", + "search_url_get_params": "search={searchTerms}&src=moz", + "suggest_url": "https://search.longdo.com/Suggest/HeadSearch", + "suggest_url_get_params": "ds=head&fxjson=1&key={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/mailcom/favicon.ico b/browser/components/search/extensions/mailcom/favicon.ico Binary files differnew file mode 100644 index 0000000000..9f1bed60f8 --- /dev/null +++ b/browser/components/search/extensions/mailcom/favicon.ico diff --git a/browser/components/search/extensions/mailcom/manifest.json b/browser/components/search/extensions/mailcom/manifest.json new file mode 100644 index 0000000000..e01016b7df --- /dev/null +++ b/browser/components/search/extensions/mailcom/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "mail.com", + "description": "mail.com", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "mailcom@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "mail.com search", + "search_url": "https://go.mail.com/br/moz_search_web/", + "search_url_get_params": "q={searchTerms}&enc=UTF-8", + "suggest_url": "https://search.mail.com/SuggestSearch/s", + "suggest_url_get_params": "q={searchTerms}&brand=mailcom&origin=br_splugin_ff_sg" + } + } +} diff --git a/browser/components/search/extensions/mailru/_locales/default/messages.json b/browser/components/search/extensions/mailru/_locales/default/messages.json new file mode 100644 index 0000000000..c902c14424 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/default/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900200" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900200&frc=900200" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900200" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/mailru001/messages.json b/browser/components/search/extensions/mailru/_locales/mailru001/messages.json new file mode 100644 index 0000000000..2b40a70dd0 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/mailru001/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900201" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900201&frc=900201" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900201" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-az/messages.json b/browser/components/search/extensions/mailru/_locales/okru-az/messages.json new file mode 100644 index 0000000000..a8b85dc600 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-az/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900209" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900209&frc=900209" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900209" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-en-US/messages.json b/browser/components/search/extensions/mailru/_locales/okru-en-US/messages.json new file mode 100644 index 0000000000..cf737cae11 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-en-US/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900205" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900205&frc=900205" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900205" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-hy-AM/messages.json b/browser/components/search/extensions/mailru/_locales/okru-hy-AM/messages.json new file mode 100644 index 0000000000..65e5ef1077 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-hy-AM/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900211" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900211&frc=900211" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900211" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-kk/messages.json b/browser/components/search/extensions/mailru/_locales/okru-kk/messages.json new file mode 100644 index 0000000000..8da96ed9b2 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-kk/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900206" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900206&frc=900206" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900206" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-ro/messages.json b/browser/components/search/extensions/mailru/_locales/okru-ro/messages.json new file mode 100644 index 0000000000..66724aba79 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-ro/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900207" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900207&frc=900207" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900207" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-ru/messages.json b/browser/components/search/extensions/mailru/_locales/okru-ru/messages.json new file mode 100644 index 0000000000..c38275f3cc --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-ru/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900203" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900203&frc=900203" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900203" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-tr/messages.json b/browser/components/search/extensions/mailru/_locales/okru-tr/messages.json new file mode 100644 index 0000000000..f3126ab63a --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-tr/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900210" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900210&frc=900210" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900210" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-uk/messages.json b/browser/components/search/extensions/mailru/_locales/okru-uk/messages.json new file mode 100644 index 0000000000..14153aa013 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-uk/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900204" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900204&frc=900204" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900204" + } +} diff --git a/browser/components/search/extensions/mailru/_locales/okru-uz/messages.json b/browser/components/search/extensions/mailru/_locales/okru-uz/messages.json new file mode 100644 index 0000000000..206a696842 --- /dev/null +++ b/browser/components/search/extensions/mailru/_locales/okru-uz/messages.json @@ -0,0 +1,11 @@ +{ + "searchForm": { + "message": "https://go.mail.ru/?gp=900208" + }, + "searchUrlGetParams": { + "message": "q={searchTerms}&fr=osmi&gp=900208&frc=900208" + }, + "suggestUrlGetParams": { + "message": "q={searchTerms}&gp=900208" + } +} diff --git a/browser/components/search/extensions/mailru/favicon.ico b/browser/components/search/extensions/mailru/favicon.ico Binary files differnew file mode 100644 index 0000000000..a2d3a48883 --- /dev/null +++ b/browser/components/search/extensions/mailru/favicon.ico diff --git a/browser/components/search/extensions/mailru/manifest.json b/browser/components/search/extensions/mailru/manifest.json new file mode 100644 index 0000000000..28bebde8d0 --- /dev/null +++ b/browser/components/search/extensions/mailru/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "Поиск Mail.Ru", + "description": "Search with Поиск Mail.Ru", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "mailru@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "default", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Поиск Mail.Ru", + "search_url": "https://go.mail.ru/search", + "search_form": "__MSG_searchForm__", + "search_url_get_params": "__MSG_searchUrlGetParams__", + "suggest_url": "https://suggests.go.mail.ru/ff3", + "suggest_url_get_params": "__MSG_suggestUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/mapy-cz/favicon.ico b/browser/components/search/extensions/mapy-cz/favicon.ico Binary files differnew file mode 100644 index 0000000000..051204c35c --- /dev/null +++ b/browser/components/search/extensions/mapy-cz/favicon.ico diff --git a/browser/components/search/extensions/mapy-cz/manifest.json b/browser/components/search/extensions/mapy-cz/manifest.json new file mode 100644 index 0000000000..b6aa3c6b67 --- /dev/null +++ b/browser/components/search/extensions/mapy-cz/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Mapy.cz", + "description": "Vyhledávání na Mapy.cz", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "mapy-cz@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Mapy.cz", + "search_url": "https://www.mapy.cz/", + "search_form": "https://www.mapy.cz/", + "search_url_get_params": "q={searchTerms}&sourceid=Searchmodule_3" + } + } +} diff --git a/browser/components/search/extensions/mercadolibre/_locales/ar/messages.json b/browser/components/search/extensions/mercadolibre/_locales/ar/messages.json new file mode 100644 index 0000000000..b83f37c6fc --- /dev/null +++ b/browser/components/search/extensions/mercadolibre/_locales/ar/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "MercadoLibre Argentina" + }, + "extensionDescription": { + "message": "MercadoLibre Argentina" + }, + "searchUrl": { + "message": "https://www.mercadolibre.com.ar/jm/search" + }, + "searchForm": { + "message": "https://www.mercadolibre.com.ar/" + }, + "searchUrlGetParams": { + "message": "as_word={searchTerms}" + } +} diff --git a/browser/components/search/extensions/mercadolibre/_locales/cl/messages.json b/browser/components/search/extensions/mercadolibre/_locales/cl/messages.json new file mode 100644 index 0000000000..3c37756464 --- /dev/null +++ b/browser/components/search/extensions/mercadolibre/_locales/cl/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "MercadoLibre Chile" + }, + "extensionDescription": { + "message": "MercadoLibre Chile" + }, + "searchUrl": { + "message": "https://www.mercadolibre.cl/jm/search" + }, + "searchForm": { + "message": "https://www.mercadolibre.cl/" + }, + "searchUrlGetParams": { + "message": "as_word={searchTerms}" + } +} diff --git a/browser/components/search/extensions/mercadolibre/_locales/mx/messages.json b/browser/components/search/extensions/mercadolibre/_locales/mx/messages.json new file mode 100644 index 0000000000..cb4d2b4b79 --- /dev/null +++ b/browser/components/search/extensions/mercadolibre/_locales/mx/messages.json @@ -0,0 +1,17 @@ +{ + "extensionName": { + "message": "MercadoLibre Mexico" + }, + "extensionDescription": { + "message": "MercadoLibre Mexico" + }, + "searchUrl": { + "message": "https://www.mercadolibre.com.mx/jm/search" + }, + "searchForm": { + "message": "https://www.mercadolibre.com.mx/" + }, + "searchUrlGetParams": { + "message": "as_word={searchTerms}" + } +} diff --git a/browser/components/search/extensions/mercadolibre/favicon.ico b/browser/components/search/extensions/mercadolibre/favicon.ico Binary files differnew file mode 100644 index 0000000000..dc9ad5b2a9 --- /dev/null +++ b/browser/components/search/extensions/mercadolibre/favicon.ico diff --git a/browser/components/search/extensions/mercadolibre/manifest.json b/browser/components/search/extensions/mercadolibre/manifest.json new file mode 100644 index 0000000000..32f5d4f9e2 --- /dev/null +++ b/browser/components/search/extensions/mercadolibre/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "mercadolibre@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "ar", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/mercadolivre/favicon.ico b/browser/components/search/extensions/mercadolivre/favicon.ico Binary files differnew file mode 100644 index 0000000000..dc9ad5b2a9 --- /dev/null +++ b/browser/components/search/extensions/mercadolivre/favicon.ico diff --git a/browser/components/search/extensions/mercadolivre/manifest.json b/browser/components/search/extensions/mercadolivre/manifest.json new file mode 100644 index 0000000000..bccfa2f0e4 --- /dev/null +++ b/browser/components/search/extensions/mercadolivre/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "MercadoLivre", + "description": "Onde comprar e vender de Tudo.", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "mercadolivre@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "MercadoLivre", + "search_url": "https://www.mercadolivre.com.br/jm/search", + "search_form": "https://www.mercadolivre.com.br/", + "search_url_get_params": "as_word={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/naver-kr/favicon.ico b/browser/components/search/extensions/naver-kr/favicon.ico Binary files differnew file mode 100644 index 0000000000..eed93a92cb --- /dev/null +++ b/browser/components/search/extensions/naver-kr/favicon.ico diff --git a/browser/components/search/extensions/naver-kr/manifest.json b/browser/components/search/extensions/naver-kr/manifest.json new file mode 100644 index 0000000000..3c866e9066 --- /dev/null +++ b/browser/components/search/extensions/naver-kr/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "네이버", + "description": "네이버 검색", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "naver-kr@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "네이버", + "search_url": "https://search.naver.com/search.naver", + "search_form": "https://search.naver.com", + "search_url_get_params": "where=nexearch&frm=ff&sm=oss&ie=utf8&query={searchTerms}", + "suggest_url": "https://ac.search.naver.com/nx/ac", + "suggest_url_get_params": "of=os&ie=utf-8&q={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/odpiralni/favicon.png b/browser/components/search/extensions/odpiralni/favicon.png Binary files differnew file mode 100644 index 0000000000..044d4f13d4 --- /dev/null +++ b/browser/components/search/extensions/odpiralni/favicon.png diff --git a/browser/components/search/extensions/odpiralni/manifest.json b/browser/components/search/extensions/odpiralni/manifest.json new file mode 100644 index 0000000000..fdcb90e5ba --- /dev/null +++ b/browser/components/search/extensions/odpiralni/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Odpiralni Časi", + "description": "Odpiralni Časi v Sloveniji", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "odpiralni@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "web_accessible_resources": ["favicon.png"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Odpiralni Časi", + "search_url": "https://www.odpiralnicasi.com/spots", + "search_url_get_params": "q={searchTerms}&source=1" + } + } +} diff --git a/browser/components/search/extensions/pazaruvaj/favicon.ico b/browser/components/search/extensions/pazaruvaj/favicon.ico Binary files differnew file mode 100644 index 0000000000..36f0cff233 --- /dev/null +++ b/browser/components/search/extensions/pazaruvaj/favicon.ico diff --git a/browser/components/search/extensions/pazaruvaj/manifest.json b/browser/components/search/extensions/pazaruvaj/manifest.json new file mode 100644 index 0000000000..dec393e303 --- /dev/null +++ b/browser/components/search/extensions/pazaruvaj/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Pazaruvaj", + "description": "Надежден помощник за покупки, сравнение на цени, онлайн магазини, описания, мнения, видеоклипове", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "pazaruvaj@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Pazaruvaj", + "search_url": "https://www.pazaruvaj.com/CategorySearch.php", + "search_form": "https://www.pazaruvaj.com/", + "search_url_get_params": "st={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/priberam/favicon.png b/browser/components/search/extensions/priberam/favicon.png Binary files differnew file mode 100644 index 0000000000..98924439d5 --- /dev/null +++ b/browser/components/search/extensions/priberam/favicon.png diff --git a/browser/components/search/extensions/priberam/manifest.json b/browser/components/search/extensions/priberam/manifest.json new file mode 100644 index 0000000000..ef4aba79ac --- /dev/null +++ b/browser/components/search/extensions/priberam/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Priberam", + "description": "Dicionário Priberam", + "manifest_version": 2, + "version": "1.3", + "browser_specific_settings": { + "gecko": { + "id": "priberam@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "web_accessible_resources": ["favicon.png"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Priberam", + "encoding": "ISO-8859-15", + "search_url": "https://www.priberam.pt/dlpo/firefox.aspx", + "search_form": "https://www.priberam.pt/dlpo/", + "search_url_get_params": "pal={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/prisjakt-sv-SE/favicon.ico b/browser/components/search/extensions/prisjakt-sv-SE/favicon.ico Binary files differnew file mode 100644 index 0000000000..feac665f71 --- /dev/null +++ b/browser/components/search/extensions/prisjakt-sv-SE/favicon.ico diff --git a/browser/components/search/extensions/prisjakt-sv-SE/manifest.json b/browser/components/search/extensions/prisjakt-sv-SE/manifest.json new file mode 100644 index 0000000000..ee65ba4f56 --- /dev/null +++ b/browser/components/search/extensions/prisjakt-sv-SE/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Prisjakt", + "description": "Prisjakt - jämför priser och produkter", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "prisjakt-sv-SE@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Prisjakt", + "search_url": "https://www.prisjakt.nu/search", + "search_url_get_params": "search={searchTerms}", + "search_form": "https://www.prisjakt.nu/search", + "suggest_url": "https://www.prisjakt.nu/plugins/opensearch/suggestions.php", + "suggest_url_get_params": "search={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/qwant/favicon.ico b/browser/components/search/extensions/qwant/favicon.ico Binary files differnew file mode 100644 index 0000000000..d43d1d5aa6 --- /dev/null +++ b/browser/components/search/extensions/qwant/favicon.ico diff --git a/browser/components/search/extensions/qwant/manifest.json b/browser/components/search/extensions/qwant/manifest.json new file mode 100644 index 0000000000..cceb5994cb --- /dev/null +++ b/browser/components/search/extensions/qwant/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Qwant", + "manifest_version": 2, + "version": "1.4", + "browser_specific_settings": { + "gecko": { + "id": "qwant@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@qwant", + "name": "Qwant", + "search_url": "https://www.qwant.com/", + "search_url_get_params": "client=brz-moz&q={searchTerms}", + "suggest_url": "https://api.qwant.com/api/suggest/", + "suggest_url_get_params": "client=opensearch&q={searchTerms}", + "search_form": "https://www.qwant.com/" + } + } +} diff --git a/browser/components/search/extensions/qwantjr/favicon.ico b/browser/components/search/extensions/qwantjr/favicon.ico Binary files differnew file mode 100644 index 0000000000..d43d1d5aa6 --- /dev/null +++ b/browser/components/search/extensions/qwantjr/favicon.ico diff --git a/browser/components/search/extensions/qwantjr/manifest.json b/browser/components/search/extensions/qwantjr/manifest.json new file mode 100644 index 0000000000..fbeab3570b --- /dev/null +++ b/browser/components/search/extensions/qwantjr/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Qwant Junior", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "qwantjr@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Qwant Junior", + "search_url": "https://www.qwantjunior.com/", + "search_url_get_params": "q={searchTerms}&client=firefoxqwant", + "suggest_url": "https://api.qwant.com/egp/suggest/", + "suggest_url_get_params": "q={searchTerms}&client=opensearch", + "search_form": "https://www.qwantjunior.com/" + } + } +} diff --git a/browser/components/search/extensions/rakuten/favicon.ico b/browser/components/search/extensions/rakuten/favicon.ico Binary files differnew file mode 100644 index 0000000000..66afe98469 --- /dev/null +++ b/browser/components/search/extensions/rakuten/favicon.ico diff --git a/browser/components/search/extensions/rakuten/manifest.json b/browser/components/search/extensions/rakuten/manifest.json new file mode 100644 index 0000000000..4ce1ffcd7a --- /dev/null +++ b/browser/components/search/extensions/rakuten/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "楽天市場", + "description": "楽天市場 商品検索", + "manifest_version": 2, + "version": "1.3", + "browser_specific_settings": { + "gecko": { + "id": "rakuten@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "楽天市場", + "encoding": "EUC-JP", + "search_url": "https://pt.afl.rakuten.co.jp/c/013ca98b.cd7c5f0c/", + "search_form": "https://www.rakuten.co.jp/", + "search_url_get_params": "sitem={searchTerms}&sv=2&p=0" + } + } +} diff --git a/browser/components/search/extensions/readmoo/favicon.ico b/browser/components/search/extensions/readmoo/favicon.ico Binary files differnew file mode 100644 index 0000000000..75396dc9ca --- /dev/null +++ b/browser/components/search/extensions/readmoo/favicon.ico diff --git a/browser/components/search/extensions/readmoo/manifest.json b/browser/components/search/extensions/readmoo/manifest.json new file mode 100644 index 0000000000..4c3622fed4 --- /dev/null +++ b/browser/components/search/extensions/readmoo/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Readmoo 讀墨電子書", + "description": "Readmoo 讀墨電子書", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "readmoo@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Readmoo 讀墨電子書", + "search_url": "https://readmoo.com/search/keyword", + "search_form": "https://readmoo.com/search/keyword?pi=0&st=true", + "search_url_get_params": "pi=0&q={searchTerms}&st=true" + } + } +} diff --git a/browser/components/search/extensions/salidzinilv/favicon.ico b/browser/components/search/extensions/salidzinilv/favicon.ico Binary files differnew file mode 100644 index 0000000000..0a7d01cae8 --- /dev/null +++ b/browser/components/search/extensions/salidzinilv/favicon.ico diff --git a/browser/components/search/extensions/salidzinilv/manifest.json b/browser/components/search/extensions/salidzinilv/manifest.json new file mode 100644 index 0000000000..bc444d5dcf --- /dev/null +++ b/browser/components/search/extensions/salidzinilv/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Salidzini.lv", + "description": "Salidzini.lv - Latvijas interneta veikalu mekletajs", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "salidzinilv@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Salidzini.lv", + "search_url": "https://www.salidzini.lv/search.php", + "search_form": "https://salidzini.lv", + "search_url_get_params": "q={searchTerms}&utm_source=firefox-plugin", + "suggest_url": "https://www.salidzini.lv/search_suggest_opensearch.php", + "suggest_url_get_params": "q={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/seznam-cz/favicon.ico b/browser/components/search/extensions/seznam-cz/favicon.ico Binary files differnew file mode 100644 index 0000000000..f3e078a107 --- /dev/null +++ b/browser/components/search/extensions/seznam-cz/favicon.ico diff --git a/browser/components/search/extensions/seznam-cz/manifest.json b/browser/components/search/extensions/seznam-cz/manifest.json new file mode 100644 index 0000000000..eaa0e1b11f --- /dev/null +++ b/browser/components/search/extensions/seznam-cz/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "Seznam", + "description": "Vyhledávání na Seznam.cz", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "seznam-cz@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Seznam", + "search_url": "https://search.seznam.cz/", + "search_form": "https://search.seznam.cz/", + "search_url_get_params": "q={searchTerms}&sourceid=firefox", + "suggest_url": "https://suggest.seznam.cz/fulltext_ff", + "suggest_url_get_params": "phrase={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/tyda-sv-SE/favicon.ico b/browser/components/search/extensions/tyda-sv-SE/favicon.ico Binary files differnew file mode 100644 index 0000000000..7415cbb160 --- /dev/null +++ b/browser/components/search/extensions/tyda-sv-SE/favicon.ico diff --git a/browser/components/search/extensions/tyda-sv-SE/manifest.json b/browser/components/search/extensions/tyda-sv-SE/manifest.json new file mode 100644 index 0000000000..cb8d1b3951 --- /dev/null +++ b/browser/components/search/extensions/tyda-sv-SE/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Tyda.se", + "description": "Tyda.se, lexikon, ordlista och översättning.", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "tyda-sv-SE@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Tyda.se", + "search_url": "https://tyda.se", + "search_form": "https://tyda.se", + "search_url_get_params": "w={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/vatera/favicon.ico b/browser/components/search/extensions/vatera/favicon.ico Binary files differnew file mode 100644 index 0000000000..5b02f16cb9 --- /dev/null +++ b/browser/components/search/extensions/vatera/favicon.ico diff --git a/browser/components/search/extensions/vatera/manifest.json b/browser/components/search/extensions/vatera/manifest.json new file mode 100644 index 0000000000..565f7e1af8 --- /dev/null +++ b/browser/components/search/extensions/vatera/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Vatera.hu", + "description": "Keresés a Vatera.hu piacterén", + "manifest_version": 2, + "version": "1.3", + "browser_specific_settings": { + "gecko": { + "id": "vatera@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Vatera.hu", + "encoding": "ISO-8859-2", + "search_url": "https://www.vatera.hu/listings/index.php", + "search_form": "https://www.vatera.hu/", + "search_url_get_params": "q={searchTerms}&c=0&td=on" + } + } +} diff --git a/browser/components/search/extensions/webde/favicon.ico b/browser/components/search/extensions/webde/favicon.ico Binary files differnew file mode 100644 index 0000000000..f0ef93d209 --- /dev/null +++ b/browser/components/search/extensions/webde/favicon.ico diff --git a/browser/components/search/extensions/webde/manifest.json b/browser/components/search/extensions/webde/manifest.json new file mode 100644 index 0000000000..25adbb03ab --- /dev/null +++ b/browser/components/search/extensions/webde/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "WEB.DE Suche", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "webde@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "WEB.DE Suche", + "search_url": "https://go.web.de/br/moz_search_web/", + "search_url_get_params": "q={searchTerms}&enc=UTF-8", + "suggest_url": "https://suggestplugin.ui-portal.de/s", + "suggest_url_get_params": "q={searchTerms}&brand=webde&origin=br_splugin_ff_sg" + } + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/NN/messages.json b/browser/components/search/extensions/wikipedia/_locales/NN/messages.json new file mode 100644 index 0000000000..f634d94ca8 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/NN/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (nn)" + }, + "extensionDescription": { + "message": "Wikipedia, det frie oppslagsverket" + }, + "searchUrl": { + "message": "https://nn.wikipedia.org/wiki/Spesial:Søk" + }, + "searchForm": { + "message": "https://nn.wikipedia.org/wiki/Spesial:Søk" + }, + "suggestUrl": { + "message": "https://nn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/NO/messages.json b/browser/components/search/extensions/wikipedia/_locales/NO/messages.json new file mode 100644 index 0000000000..0e83173b7a --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/NO/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (no)" + }, + "extensionDescription": { + "message": "Wikipedia, den frie encyklopedi" + }, + "searchUrl": { + "message": "https://no.wikipedia.org/wiki/Spesial:Søk" + }, + "searchForm": { + "message": "https://no.wikipedia.org/wiki/Spesial:Søk" + }, + "suggestUrl": { + "message": "https://no.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/af/messages.json b/browser/components/search/extensions/wikipedia/_locales/af/messages.json new file mode 100644 index 0000000000..382e3e1412 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/af/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (af)" + }, + "extensionDescription": { + "message": "Wikipedia, die vrye ensiklopedie" + }, + "searchUrl": { + "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek" + }, + "searchForm": { + "message": "https://af.wikipedia.org/wiki/Spesiaal:Soek" + }, + "suggestUrl": { + "message": "https://af.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/an/messages.json b/browser/components/search/extensions/wikipedia/_locales/an/messages.json new file mode 100644 index 0000000000..d205a6b0a7 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/an/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Biquipedia (an)" + }, + "extensionDescription": { + "message": "A enciclopedia Libre" + }, + "searchUrl": { + "message": "https://an.wikipedia.org/wiki/Especial:Mirar" + }, + "searchForm": { + "message": "https://an.wikipedia.org/wiki/Especial:Mirar" + }, + "suggestUrl": { + "message": "https://an.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ar/messages.json b/browser/components/search/extensions/wikipedia/_locales/ar/messages.json new file mode 100644 index 0000000000..0d0fe38ceb --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ar/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "ويكيبيديا (ar)" + }, + "extensionDescription": { + "message": "ويكيبيديا (ar)" + }, + "searchUrl": { + "message": "https://ar.wikipedia.org/wiki/خاص:بحث" + }, + "searchForm": { + "message": "https://ar.wikipedia.org/wiki/خاص:بحث" + }, + "suggestUrl": { + "message": "https://ar.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ast/messages.json b/browser/components/search/extensions/wikipedia/_locales/ast/messages.json new file mode 100644 index 0000000000..b0b17cdb2a --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ast/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (ast)" + }, + "extensionDescription": { + "message": "La enciclopedia llibre" + }, + "searchUrl": { + "message": "https://ast.wikipedia.org/wiki/Especial:Gueta" + }, + "searchForm": { + "message": "https://ast.wikipedia.org/wiki/Especial:Gueta" + }, + "suggestUrl": { + "message": "https://ast.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/az/messages.json b/browser/components/search/extensions/wikipedia/_locales/az/messages.json new file mode 100644 index 0000000000..b67a299a48 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/az/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vikipediya (az)" + }, + "extensionDescription": { + "message": "Vikipediya, açıq ensiklopediya" + }, + "searchUrl": { + "message": "https://az.wikipedia.org/wiki/Xüsusi:Axtar" + }, + "searchForm": { + "message": "https://az.wikipedia.org/wiki/Xüsusi:Axtar" + }, + "suggestUrl": { + "message": "https://az.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/be-tarask/messages.json b/browser/components/search/extensions/wikipedia/_locales/be-tarask/messages.json new file mode 100644 index 0000000000..eea6296bf1 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/be-tarask/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Вікіпэдыя (be-tarask)" + }, + "extensionDescription": { + "message": "Вікіпэдыя, вольная энцыкляпэдыя" + }, + "searchUrl": { + "message": "https://be-tarask.wikipedia.org/wiki/Спэцыяльныя:Пошук" + }, + "searchForm": { + "message": "https://be-tarask.wikipedia.org/wiki/Спэцыяльныя:Пошук" + }, + "suggestUrl": { + "message": "https://be-tarask.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/be/messages.json b/browser/components/search/extensions/wikipedia/_locales/be/messages.json new file mode 100644 index 0000000000..c826d36246 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/be/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Вікіпедыя (be)" + }, + "extensionDescription": { + "message": "Вікіпедыя, свабодная энцыклапедыя" + }, + "searchUrl": { + "message": "https://be.wikipedia.org/wiki/Адмысловае:Search" + }, + "searchForm": { + "message": "https://be.wikipedia.org/wiki/Адмысловае:Search" + }, + "suggestUrl": { + "message": "https://be.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/bg/messages.json b/browser/components/search/extensions/wikipedia/_locales/bg/messages.json new file mode 100644 index 0000000000..26d103285c --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/bg/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Уикипедия (bg)" + }, + "extensionDescription": { + "message": "Уикипедия, свободната енциклоподия" + }, + "searchUrl": { + "message": "https://bg.wikipedia.org/wiki/Специални:Търсене" + }, + "searchForm": { + "message": "https://bg.wikipedia.org/wiki/Специални:Търсене" + }, + "suggestUrl": { + "message": "https://bg.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/bn/messages.json b/browser/components/search/extensions/wikipedia/_locales/bn/messages.json new file mode 100644 index 0000000000..afe7d94a8d --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/bn/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "উইকিপিডিয়া (bn)" + }, + "extensionDescription": { + "message": "উইকিপিডিয়া, মুক্ত বিশ্বকোষ" + }, + "searchUrl": { + "message": "https://bn.wikipedia.org/wiki/বিশেষ:Search" + }, + "searchForm": { + "message": "https://bn.wikipedia.org/wiki/বিশেষ:Search" + }, + "suggestUrl": { + "message": "https://bn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/br/messages.json b/browser/components/search/extensions/wikipedia/_locales/br/messages.json new file mode 100644 index 0000000000..ed0e453280 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/br/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (br)" + }, + "extensionDescription": { + "message": "Wikipedia, an holloueziadur digor" + }, + "searchUrl": { + "message": "https://br.wikipedia.org/wiki/Dibar:Klask" + }, + "searchForm": { + "message": "https://br.wikipedia.org/wiki/Dibar:Klask" + }, + "suggestUrl": { + "message": "https://br.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/bs/messages.json b/browser/components/search/extensions/wikipedia/_locales/bs/messages.json new file mode 100644 index 0000000000..00932991c6 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/bs/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (bs)" + }, + "extensionDescription": { + "message": "Slobodna enciklopedija" + }, + "searchUrl": { + "message": "https://bs.wikipedia.org/wiki/Posebno:Pretraga" + }, + "searchForm": { + "message": "https://bs.wikipedia.org/wiki/Posebno:Pretraga" + }, + "suggestUrl": { + "message": "https://bs.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ca/messages.json b/browser/components/search/extensions/wikipedia/_locales/ca/messages.json new file mode 100644 index 0000000000..852be81415 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ca/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Viquipèdia (ca)" + }, + "extensionDescription": { + "message": "L'enciclopèdia lliure" + }, + "searchUrl": { + "message": "https://ca.wikipedia.org/wiki/Especial:Cerca" + }, + "searchForm": { + "message": "https://ca.wikipedia.org/wiki/Especial:Cerca" + }, + "suggestUrl": { + "message": "https://ca.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/cy/messages.json b/browser/components/search/extensions/wikipedia/_locales/cy/messages.json new file mode 100644 index 0000000000..d8522ffafc --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/cy/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wicipedia (cy)" + }, + "extensionDescription": { + "message": "Wicipedia, Y Gwyddioniadur Rhydd" + }, + "searchUrl": { + "message": "https://cy.wikipedia.org/wiki/Arbennig:Search" + }, + "searchForm": { + "message": "https://cy.wikipedia.org/wiki/Arbennig:Search" + }, + "suggestUrl": { + "message": "https://cy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/cz/messages.json b/browser/components/search/extensions/wikipedia/_locales/cz/messages.json new file mode 100644 index 0000000000..0da42bcd87 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/cz/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedie (cs)" + }, + "extensionDescription": { + "message": "Wikipedia, svobodná encyclopedie" + }, + "searchUrl": { + "message": "https://cs.wikipedia.org/wiki/Speciální:Hledání" + }, + "searchForm": { + "message": "https://cs.wikipedia.org/wiki/Speciální:Hledání" + }, + "suggestUrl": { + "message": "https://cs.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/da/messages.json b/browser/components/search/extensions/wikipedia/_locales/da/messages.json new file mode 100644 index 0000000000..bdca8de0d0 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/da/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (da)" + }, + "extensionDescription": { + "message": "Wikipedia, den frie encyklopædi" + }, + "searchUrl": { + "message": "https://da.wikipedia.org/wiki/Speciel:Søgning" + }, + "searchForm": { + "message": "https://da.wikipedia.org/wiki/Speciel:Søgning" + }, + "suggestUrl": { + "message": "https://da.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/de/messages.json b/browser/components/search/extensions/wikipedia/_locales/de/messages.json new file mode 100644 index 0000000000..102032708b --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/de/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (de)" + }, + "extensionDescription": { + "message": "Wikipedia, die freie Enzyklopädie" + }, + "searchUrl": { + "message": "https://de.wikipedia.org/wiki/Spezial:Suche" + }, + "searchForm": { + "message": "https://de.wikipedia.org/wiki/Spezial:Suche" + }, + "suggestUrl": { + "message": "https://de.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/dsb/messages.json b/browser/components/search/extensions/wikipedia/_locales/dsb/messages.json new file mode 100644 index 0000000000..cc0ce903d0 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/dsb/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedija (dsb)" + }, + "extensionDescription": { + "message": "Wikipedija, lichotna encyklopedija" + }, + "searchUrl": { + "message": "https://dsb.wikipedia.org/wiki/Specialne:Pytaś" + }, + "searchForm": { + "message": "https://dsb.wikipedia.org/wiki/Specialne:Pytaś" + }, + "suggestUrl": { + "message": "https://dsb.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/el/messages.json b/browser/components/search/extensions/wikipedia/_locales/el/messages.json new file mode 100644 index 0000000000..5225a298d6 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/el/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (el)" + }, + "extensionDescription": { + "message": "Βικιπαίδεια, η ελεύθερη εγκυκλοπαίδεια" + }, + "searchUrl": { + "message": "https://el.wikipedia.org/wiki/Ειδικό:Αναζήτηση" + }, + "searchForm": { + "message": "https://el.wikipedia.org/wiki/Ειδικό:Αναζήτηση" + }, + "suggestUrl": { + "message": "https://el.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/en/messages.json b/browser/components/search/extensions/wikipedia/_locales/en/messages.json new file mode 100644 index 0000000000..f94458bb55 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/en/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (en)" + }, + "extensionDescription": { + "message": "Wikipedia, the Free Encyclopedia" + }, + "searchUrl": { + "message": "https://en.wikipedia.org/wiki/Special:Search" + }, + "searchForm": { + "message": "https://en.wikipedia.org/wiki/Special:Search" + }, + "suggestUrl": { + "message": "https://en.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/eo/messages.json b/browser/components/search/extensions/wikipedia/_locales/eo/messages.json new file mode 100644 index 0000000000..9ed9398408 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/eo/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vikipedio (eo)" + }, + "extensionDescription": { + "message": "Vikipedio, la libera enciklopedio" + }, + "searchUrl": { + "message": "https://eo.wikipedia.org/wiki/Specialaĵo:Serĉi" + }, + "searchForm": { + "message": "https://eo.wikipedia.org/wiki/Specialaĵo:Serĉi" + }, + "suggestUrl": { + "message": "https://eo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/es/messages.json b/browser/components/search/extensions/wikipedia/_locales/es/messages.json new file mode 100644 index 0000000000..ced826eb40 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/es/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (es)" + }, + "extensionDescription": { + "message": "Wikipedia, la enciclopedia libre" + }, + "searchUrl": { + "message": "https://es.wikipedia.org/wiki/Especial:Buscar" + }, + "searchForm": { + "message": "https://es.wikipedia.org/wiki/Especial:Buscar" + }, + "suggestUrl": { + "message": "https://es.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/et/messages.json b/browser/components/search/extensions/wikipedia/_locales/et/messages.json new file mode 100644 index 0000000000..16812519da --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/et/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vikipeedia (et)" + }, + "extensionDescription": { + "message": "Vikipeedia, vaba entsüklopeedia" + }, + "searchUrl": { + "message": "https://et.wikipedia.org/wiki/Eri:Otsimine" + }, + "searchForm": { + "message": "https://et.wikipedia.org/wiki/Eri:Otsimine" + }, + "suggestUrl": { + "message": "https://et.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/eu/messages.json b/browser/components/search/extensions/wikipedia/_locales/eu/messages.json new file mode 100644 index 0000000000..f1adb3b383 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/eu/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (eu)" + }, + "extensionDescription": { + "message": "Wikipedia, entziklopedia askea" + }, + "searchUrl": { + "message": "https://eu.wikipedia.org/wiki/Berezi:Bilatu" + }, + "searchForm": { + "message": "https://eu.wikipedia.org/wiki/Berezi:Bilatu" + }, + "suggestUrl": { + "message": "https://eu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/fa/messages.json b/browser/components/search/extensions/wikipedia/_locales/fa/messages.json new file mode 100644 index 0000000000..08f13c1f22 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/fa/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "ویکیپدیا (fa)" + }, + "extensionDescription": { + "message": "ویکیپدیا، دانشنامهٔ آزاد" + }, + "searchUrl": { + "message": "https://fa.wikipedia.org/wiki/ویژه:جستجو" + }, + "searchForm": { + "message": "https://fa.wikipedia.org/wiki/ویژه:جستجو" + }, + "suggestUrl": { + "message": "https://fa.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/fi/messages.json b/browser/components/search/extensions/wikipedia/_locales/fi/messages.json new file mode 100644 index 0000000000..2abb8282d3 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/fi/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (fi)" + }, + "extensionDescription": { + "message": "Wikipedia (fi), vapaa tietosanakirja" + }, + "searchUrl": { + "message": "https://fi.wikipedia.org/wiki/Toiminnot:Haku" + }, + "searchForm": { + "message": "https://fi.wikipedia.org/wiki/Toiminnot:Haku" + }, + "suggestUrl": { + "message": "https://fi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/fr/messages.json b/browser/components/search/extensions/wikipedia/_locales/fr/messages.json new file mode 100644 index 0000000000..e1b4aeffb7 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/fr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipédia (fr)" + }, + "extensionDescription": { + "message": "Wikipédia, l'encyclopédie libre" + }, + "searchUrl": { + "message": "https://fr.wikipedia.org/wiki/Spécial:Recherche" + }, + "searchForm": { + "message": "https://fr.wikipedia.org/wiki/Spécial:Recherche" + }, + "suggestUrl": { + "message": "https://fr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/fy-NL/messages.json b/browser/components/search/extensions/wikipedia/_locales/fy-NL/messages.json new file mode 100644 index 0000000000..bfad9c2a6c --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/fy-NL/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedy (fy)" + }, + "extensionDescription": { + "message": "De fergese ensyklopedy" + }, + "searchUrl": { + "message": "https://fy.wikipedia.org/wiki/Wiki:Sykje" + }, + "searchForm": { + "message": "https://fy.wikipedia.org/wiki/Wiki:Sykje" + }, + "suggestUrl": { + "message": "https://fy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ga-IE/messages.json b/browser/components/search/extensions/wikipedia/_locales/ga-IE/messages.json new file mode 100644 index 0000000000..ae350f7df6 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ga-IE/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vicipéid (ga)" + }, + "extensionDescription": { + "message": "Vicipéid, an Chiclipéid Shaor" + }, + "searchUrl": { + "message": "https://ga.wikipedia.org/wiki/Speisialta:Search" + }, + "searchForm": { + "message": "https://ga.wikipedia.org/wiki/Speisialta:Search" + }, + "suggestUrl": { + "message": "https://ga.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/gd/messages.json b/browser/components/search/extensions/wikipedia/_locales/gd/messages.json new file mode 100644 index 0000000000..178b67c7b6 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/gd/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Uicipeid (gd)" + }, + "extensionDescription": { + "message": "Wikipedia, An leabhar mòr-eòlais" + }, + "searchUrl": { + "message": "https://gd.wikipedia.org/wiki/Sònraichte:Search" + }, + "searchForm": { + "message": "https://gd.wikipedia.org/wiki/Sònraichte:Search" + }, + "suggestUrl": { + "message": "https://gd.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/gl/messages.json b/browser/components/search/extensions/wikipedia/_locales/gl/messages.json new file mode 100644 index 0000000000..97309277a3 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/gl/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (gl)" + }, + "extensionDescription": { + "message": "Wikipedia, a enciclopedia libre" + }, + "searchUrl": { + "message": "https://gl.wikipedia.org/wiki/Especial:Procurar" + }, + "searchForm": { + "message": "https://gl.wikipedia.org/wiki/Especial:Procurar" + }, + "suggestUrl": { + "message": "https://gl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/gn/messages.json b/browser/components/search/extensions/wikipedia/_locales/gn/messages.json new file mode 100644 index 0000000000..0628e96ac9 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/gn/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vikipetã (gn)" + }, + "extensionDescription": { + "message": "Vikipetã, opaite tembikuaa hekosãsóva renda" + }, + "searchUrl": { + "message": "https://gn.wikipedia.org/wiki/Mba'echĩchĩ:Buscar" + }, + "searchForm": { + "message": "https://gn.wikipedia.org/wiki/Mba'echĩchĩ:Buscar" + }, + "suggestUrl": { + "message": "https://gn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/gu/messages.json b/browser/components/search/extensions/wikipedia/_locales/gu/messages.json new file mode 100644 index 0000000000..b9dacaf138 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/gu/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "વિકિપીડિયા (gu)" + }, + "extensionDescription": { + "message": "વીકીપીડિયા, મુક્ત એનસાયક્લોપીડિયા" + }, + "searchUrl": { + "message": "https://gu.wikipedia.org/wiki/વિશેષ:શોધ" + }, + "searchForm": { + "message": "https://gu.wikipedia.org/wiki/વિશેષ:શોધ" + }, + "suggestUrl": { + "message": "https://gu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/he/messages.json b/browser/components/search/extensions/wikipedia/_locales/he/messages.json new file mode 100644 index 0000000000..8189c73983 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/he/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "ויקיפדיה" + }, + "extensionDescription": { + "message": "ויקיפדיה" + }, + "searchUrl": { + "message": "https://he.wikipedia.org/wiki/מיוחד:חיפוש" + }, + "searchForm": { + "message": "https://he.wikipedia.org/wiki/מיוחד:חיפוש" + }, + "suggestUrl": { + "message": "https://he.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/hi/messages.json b/browser/components/search/extensions/wikipedia/_locales/hi/messages.json new file mode 100644 index 0000000000..5765c26de8 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/hi/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "विकिपीडिया (hi)" + }, + "extensionDescription": { + "message": "विकिपीडिया (हिन्दी)" + }, + "searchUrl": { + "message": "https://hi.wikipedia.org/wiki/विशेष:खोज" + }, + "searchForm": { + "message": "https://hi.wikipedia.org/wiki/विशेष:खोज" + }, + "suggestUrl": { + "message": "https://hi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/hr/messages.json b/browser/components/search/extensions/wikipedia/_locales/hr/messages.json new file mode 100644 index 0000000000..e01349bdec --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/hr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedija (hr)" + }, + "extensionDescription": { + "message": "Wikipedija, slobodna enciklopedija" + }, + "searchUrl": { + "message": "https://hr.wikipedia.org/wiki/Posebno:Traži" + }, + "searchForm": { + "message": "https://hr.wikipedia.org/wiki/Posebno:Traži" + }, + "suggestUrl": { + "message": "https://hr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/hsb/messages.json b/browser/components/search/extensions/wikipedia/_locales/hsb/messages.json new file mode 100644 index 0000000000..ace410f1ca --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/hsb/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedija (hsb)" + }, + "extensionDescription": { + "message": "Wikipedija, swobodna encyklopedija" + }, + "searchUrl": { + "message": "https://hsb.wikipedia.org/wiki/Specialnje:Pytać" + }, + "searchForm": { + "message": "https://hsb.wikipedia.org/wiki/Specialnje:Pytać" + }, + "suggestUrl": { + "message": "https://hsb.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/hu/messages.json b/browser/components/search/extensions/wikipedia/_locales/hu/messages.json new file mode 100644 index 0000000000..c853c4b51b --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/hu/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipédia (hu)" + }, + "extensionDescription": { + "message": "Wikipedia, a szabad enciklopédia" + }, + "searchUrl": { + "message": "https://hu.wikipedia.org/wiki/Speciális:Keresés" + }, + "searchForm": { + "message": "https://hu.wikipedia.org/wiki/Speciális:Keresés" + }, + "suggestUrl": { + "message": "https://hu.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/hy/messages.json b/browser/components/search/extensions/wikipedia/_locales/hy/messages.json new file mode 100644 index 0000000000..093171ed00 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/hy/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (hy)" + }, + "extensionDescription": { + "message": "Վիքիփեդիա՝ ազատ հանրագիտարան" + }, + "searchUrl": { + "message": "https://hy.wikipedia.org/wiki/Սպասարկող:Որոնել" + }, + "searchForm": { + "message": "https://hy.wikipedia.org/wiki/Սպասարկող:Որոնել" + }, + "suggestUrl": { + "message": "https://hy.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ia/messages.json b/browser/components/search/extensions/wikipedia/_locales/ia/messages.json new file mode 100644 index 0000000000..b19d0f7fbb --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ia/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (ia)" + }, + "extensionDescription": { + "message": "Wikipedia, le encyclopedia libere" + }, + "searchUrl": { + "message": "https://ia.wikipedia.org/wiki/Special:Recerca" + }, + "searchForm": { + "message": "https://ia.wikipedia.org/wiki/Special:Recerca" + }, + "suggestUrl": { + "message": "https://ia.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/id/messages.json b/browser/components/search/extensions/wikipedia/_locales/id/messages.json new file mode 100644 index 0000000000..a88a925a9b --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/id/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (id)" + }, + "extensionDescription": { + "message": "Wikipedia, ensiklopedia bebas" + }, + "searchUrl": { + "message": "https://id.wikipedia.org/wiki/Istimewa:Pencarian" + }, + "searchForm": { + "message": "https://id.wikipedia.org/wiki/Istimewa:Pencarian" + }, + "suggestUrl": { + "message": "https://id.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/is/messages.json b/browser/components/search/extensions/wikipedia/_locales/is/messages.json new file mode 100644 index 0000000000..417539d457 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/is/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (is)" + }, + "extensionDescription": { + "message": "Wikipedia, the free encyclopedia" + }, + "searchUrl": { + "message": "https://is.wikipedia.org/wiki/Kerfissíða:Leit" + }, + "searchForm": { + "message": "https://is.wikipedia.org/wiki/Kerfissíða:Leit" + }, + "suggestUrl": { + "message": "https://is.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/it/messages.json b/browser/components/search/extensions/wikipedia/_locales/it/messages.json new file mode 100644 index 0000000000..cda10354cc --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/it/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (it)" + }, + "extensionDescription": { + "message": "Wikipedia, l'enciclopedia libera" + }, + "searchUrl": { + "message": "https://it.wikipedia.org/wiki/Speciale:Ricerca" + }, + "searchForm": { + "message": "https://it.wikipedia.org/wiki/Speciale:Ricerca" + }, + "suggestUrl": { + "message": "https://it.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ja/messages.json b/browser/components/search/extensions/wikipedia/_locales/ja/messages.json new file mode 100644 index 0000000000..ef16685a68 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ja/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (ja)" + }, + "extensionDescription": { + "message": "Wikipedia - フリー百科事典" + }, + "searchUrl": { + "message": "https://ja.wikipedia.org/wiki/特別:検索" + }, + "searchForm": { + "message": "https://ja.wikipedia.org/wiki/特別:検索" + }, + "suggestUrl": { + "message": "https://ja.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ka/messages.json b/browser/components/search/extensions/wikipedia/_locales/ka/messages.json new file mode 100644 index 0000000000..c23cdbf0a5 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ka/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "ვიკიპედია (ka)" + }, + "extensionDescription": { + "message": "ვიკიპედია, თავისუფალი ენციკლოპედია" + }, + "searchUrl": { + "message": "https://ka.wikipedia.org/wiki/სპეციალური:ძიება" + }, + "searchForm": { + "message": "https://ka.wikipedia.org/wiki/სპეციალური:ძიება" + }, + "suggestUrl": { + "message": "https://ka.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/kab/messages.json b/browser/components/search/extensions/wikipedia/_locales/kab/messages.json new file mode 100644 index 0000000000..e2a156ccd6 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/kab/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (kab)" + }, + "extensionDescription": { + "message": "Wikipedia, tasanayt tilellit" + }, + "searchUrl": { + "message": "https://kab.wikipedia.org/wiki/Uslig:Search" + }, + "searchForm": { + "message": "https://kab.wikipedia.org/wiki/Uslig:Search" + }, + "suggestUrl": { + "message": "https://kab.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/kk/messages.json b/browser/components/search/extensions/wikipedia/_locales/kk/messages.json new file mode 100644 index 0000000000..d2da12ff70 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/kk/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Уикипедия (kk)" + }, + "extensionDescription": { + "message": "Уикипедия (kk)" + }, + "searchUrl": { + "message": "https://kk.wikipedia.org/wiki/Арнайы:Іздеу" + }, + "searchForm": { + "message": "https://kk.wikipedia.org/wiki/Арнайы:Іздеу" + }, + "suggestUrl": { + "message": "https://kk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/km/messages.json b/browser/components/search/extensions/wikipedia/_locales/km/messages.json new file mode 100644 index 0000000000..c8da6a1d2f --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/km/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "វីគីភីឌា (km)" + }, + "extensionDescription": { + "message": "វីគីភីឌា សព្វវចនាធិប្បាយសេរី" + }, + "searchUrl": { + "message": "https://km.wikipedia.org/wiki/ពិសេស:ស្វែងរក" + }, + "searchForm": { + "message": "https://km.wikipedia.org/wiki/ពិសេស:ស្វែងរក" + }, + "suggestUrl": { + "message": "https://km.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/kn/messages.json b/browser/components/search/extensions/wikipedia/_locales/kn/messages.json new file mode 100644 index 0000000000..8e5fc996b8 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/kn/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (kn)" + }, + "extensionDescription": { + "message": "Wikipedia, the free encyclopedia" + }, + "searchUrl": { + "message": "https://kn.wikipedia.org/wiki/ವಿಶೇಷ:Search" + }, + "searchForm": { + "message": "https://kn.wikipedia.org/wiki/ವಿಶೇಷ:Search" + }, + "suggestUrl": { + "message": "https://kn.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/kr/messages.json b/browser/components/search/extensions/wikipedia/_locales/kr/messages.json new file mode 100644 index 0000000000..6e2d4e99f3 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/kr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "위키백과 (ko)" + }, + "extensionDescription": { + "message": "Wikipedia, the free encyclopedia" + }, + "searchUrl": { + "message": "https://ko.wikipedia.org/wiki/특수기능:찾기" + }, + "searchForm": { + "message": "https://ko.wikipedia.org/wiki/특수기능:찾기" + }, + "suggestUrl": { + "message": "https://ko.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/lij/messages.json b/browser/components/search/extensions/wikipedia/_locales/lij/messages.json new file mode 100644 index 0000000000..b856f7f2d5 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/lij/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (lij)" + }, + "extensionDescription": { + "message": "Wikipedia, l'enciclopedia libera" + }, + "searchUrl": { + "message": "https://lij.wikipedia.org/wiki/Speçiale:Riçerca" + }, + "searchForm": { + "message": "https://lij.wikipedia.org/wiki/Speçiale:Riçerca" + }, + "suggestUrl": { + "message": "https://lij.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/lo/messages.json b/browser/components/search/extensions/wikipedia/_locales/lo/messages.json new file mode 100644 index 0000000000..99341253cb --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/lo/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "ວິກິພີເດຍ (lo)" + }, + "extensionDescription": { + "message": "ວິກິພີເດຍ, ສາລານຸກົມເສລີ" + }, + "searchUrl": { + "message": "https://lo.wikipedia.org/wiki/ພິເສດ:ຊອກຫາ" + }, + "searchForm": { + "message": "https://lo.wikipedia.org/wiki/ພິເສດ:ຊອກຫາ" + }, + "suggestUrl": { + "message": "https://lo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/lt/messages.json b/browser/components/search/extensions/wikipedia/_locales/lt/messages.json new file mode 100644 index 0000000000..27299e618d --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/lt/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (lt)" + }, + "extensionDescription": { + "message": "Vikipedija, laisvoji enciklopedija" + }, + "searchUrl": { + "message": "https://lt.wikipedia.org/wiki/Specialus:Paieška" + }, + "searchForm": { + "message": "https://lt.wikipedia.org/wiki/Specialus:Paieška" + }, + "suggestUrl": { + "message": "https://lt.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ltg/messages.json b/browser/components/search/extensions/wikipedia/_locales/ltg/messages.json new file mode 100644 index 0000000000..e4db21d0bb --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ltg/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vikipedeja (ltg)" + }, + "extensionDescription": { + "message": "Vikipēdija, breivuo eņciklopedeja" + }, + "searchUrl": { + "message": "https://ltg.wikipedia.org/wiki/Seviškuo:Search" + }, + "searchForm": { + "message": "https://ltg.wikipedia.org/wiki/Seviškuo:Search" + }, + "suggestUrl": { + "message": "https://ltg.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/lv/messages.json b/browser/components/search/extensions/wikipedia/_locales/lv/messages.json new file mode 100644 index 0000000000..4ddd84ce0f --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/lv/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vikipēdija" + }, + "extensionDescription": { + "message": "Vikipēdija, brīvā enciklopēdija" + }, + "searchUrl": { + "message": "https://lv.wikipedia.org/wiki/Special:Search" + }, + "searchForm": { + "message": "https://lv.wikipedia.org/wiki/Special:Search" + }, + "suggestUrl": { + "message": "https://lv.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/mk/messages.json b/browser/components/search/extensions/wikipedia/_locales/mk/messages.json new file mode 100644 index 0000000000..f894354767 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/mk/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Википедија (mk)" + }, + "extensionDescription": { + "message": "Википедија, слободната енциклопедија" + }, + "searchUrl": { + "message": "https://mk.wikipedia.org/wiki/Специјална:Барај" + }, + "searchForm": { + "message": "https://mk.wikipedia.org/wiki/Специјална:Барај" + }, + "suggestUrl": { + "message": "https://mk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/mr/messages.json b/browser/components/search/extensions/wikipedia/_locales/mr/messages.json new file mode 100644 index 0000000000..b41e92218d --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/mr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "विकिपीडिया (mr)" + }, + "extensionDescription": { + "message": "विकिपीडिया, मोफत माहितीकोष" + }, + "searchUrl": { + "message": "https://mr.wikipedia.org/wiki/विशेष:शोधा" + }, + "searchForm": { + "message": "https://mr.wikipedia.org/wiki/विशेष:शोधा" + }, + "suggestUrl": { + "message": "https://mr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ms/messages.json b/browser/components/search/extensions/wikipedia/_locales/ms/messages.json new file mode 100644 index 0000000000..e1a0b3bab8 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ms/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (ms)" + }, + "extensionDescription": { + "message": "Wikipedia, ensiklopedia bebas" + }, + "searchUrl": { + "message": "https://ms.wikipedia.org/wiki/Khas:Gelintar" + }, + "searchForm": { + "message": "https://ms.wikipedia.org/wiki/Khas:Gelintar" + }, + "suggestUrl": { + "message": "https://ms.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/my/messages.json b/browser/components/search/extensions/wikipedia/_locales/my/messages.json new file mode 100644 index 0000000000..e856786d33 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/my/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (my)" + }, + "extensionDescription": { + "message": "အခမဲ့လွတ်လပ်စွယ်စုံကျမ်း" + }, + "searchUrl": { + "message": "https://my.wikipedia.org/wiki/Special:Search" + }, + "searchForm": { + "message": "https://my.wikipedia.org/wiki/Special:Search" + }, + "suggestUrl": { + "message": "https://my.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ne/messages.json b/browser/components/search/extensions/wikipedia/_locales/ne/messages.json new file mode 100644 index 0000000000..96458e5507 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ne/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "विकिपीडिया (ne)" + }, + "extensionDescription": { + "message": "विकिपिडिया एक स्वतन्त्र विश्वकोष" + }, + "searchUrl": { + "message": "https://ne.wikipedia.org/wiki/विशेष:Search" + }, + "searchForm": { + "message": "https://ne.wikipedia.org/wiki/विशेष:Search" + }, + "suggestUrl": { + "message": "https://ne.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/nl/messages.json b/browser/components/search/extensions/wikipedia/_locales/nl/messages.json new file mode 100644 index 0000000000..9e61b67aab --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/nl/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (nl)" + }, + "extensionDescription": { + "message": "De vrije encyclopedie" + }, + "searchUrl": { + "message": "https://nl.wikipedia.org/wiki/Speciaal:Zoeken" + }, + "searchForm": { + "message": "https://nl.wikipedia.org/wiki/Speciaal:Zoeken" + }, + "suggestUrl": { + "message": "https://nl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/oc/messages.json b/browser/components/search/extensions/wikipedia/_locales/oc/messages.json new file mode 100644 index 0000000000..186438f33d --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/oc/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipèdia (oc)" + }, + "extensionDescription": { + "message": "Wikipèdia, l'enciclopèdia liura" + }, + "searchUrl": { + "message": "https://oc.wikipedia.org/wiki/Especial:Recèrca" + }, + "searchForm": { + "message": "https://oc.wikipedia.org/wiki/Especial:Recèrca" + }, + "suggestUrl": { + "message": "https://oc.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/pa/messages.json b/browser/components/search/extensions/wikipedia/_locales/pa/messages.json new file mode 100644 index 0000000000..4951bc3360 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/pa/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (pa)" + }, + "extensionDescription": { + "message": "ਵਿਕਿਪੀਡਿਆ, ਮੁਫ਼ਤ/ਮੁਕਤ ਸ਼ਬਦਕੋਸ਼" + }, + "searchUrl": { + "message": "https://pa.wikipedia.org/wiki/ਖ਼ਾਸ:ਖੋਜੋ" + }, + "searchForm": { + "message": "https://pa.wikipedia.org/wiki/ਖ਼ਾਸ:ਖੋਜੋ" + }, + "suggestUrl": { + "message": "https://pa.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/pl/messages.json b/browser/components/search/extensions/wikipedia/_locales/pl/messages.json new file mode 100644 index 0000000000..df3ace08e5 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/pl/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (pl)" + }, + "extensionDescription": { + "message": "Wikipedia, wolna encyklopedia" + }, + "searchUrl": { + "message": "https://pl.wikipedia.org/wiki/Specjalna:Szukaj" + }, + "searchForm": { + "message": "https://pl.wikipedia.org/wiki/Specjalna:Szukaj" + }, + "suggestUrl": { + "message": "https://pl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/pt/messages.json b/browser/components/search/extensions/wikipedia/_locales/pt/messages.json new file mode 100644 index 0000000000..55eb5d6620 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/pt/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (pt)" + }, + "extensionDescription": { + "message": "Wikipédia, a enciclopédia livre" + }, + "searchUrl": { + "message": "https://pt.wikipedia.org/wiki/Especial:Pesquisar" + }, + "searchForm": { + "message": "https://pt.wikipedia.org/wiki/Especial:Pesquisar" + }, + "suggestUrl": { + "message": "https://pt.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/rm/messages.json b/browser/components/search/extensions/wikipedia/_locales/rm/messages.json new file mode 100644 index 0000000000..6a49d16dec --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/rm/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (rm)" + }, + "extensionDescription": { + "message": "Vichipedia, l'enciclopedia libra" + }, + "searchUrl": { + "message": "https://rm.wikipedia.org/wiki/Spezial:Search" + }, + "searchForm": { + "message": "https://rm.wikipedia.org/wiki/Spezial:Search" + }, + "suggestUrl": { + "message": "https://rm.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ro/messages.json b/browser/components/search/extensions/wikipedia/_locales/ro/messages.json new file mode 100644 index 0000000000..f34a57f46a --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ro/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (ro)" + }, + "extensionDescription": { + "message": "Wikipedia, enciclopedia liberă" + }, + "searchUrl": { + "message": "https://ro.wikipedia.org/wiki/Special:Căutare" + }, + "searchForm": { + "message": "https://ro.wikipedia.org/wiki/Special:Căutare" + }, + "suggestUrl": { + "message": "https://ro.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ru/messages.json b/browser/components/search/extensions/wikipedia/_locales/ru/messages.json new file mode 100644 index 0000000000..295df0d802 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ru/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Википедия (ru)" + }, + "extensionDescription": { + "message": "Википедия, свободная энциклопедия" + }, + "searchUrl": { + "message": "https://ru.wikipedia.org/wiki/Служебная:Поиск" + }, + "searchForm": { + "message": "https://ru.wikipedia.org/wiki/Служебная:Поиск" + }, + "suggestUrl": { + "message": "https://ru.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/si/messages.json b/browser/components/search/extensions/wikipedia/_locales/si/messages.json new file mode 100644 index 0000000000..73bd4b55a7 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/si/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (si)" + }, + "extensionDescription": { + "message": "Wikipedia, the free encyclopedia" + }, + "searchUrl": { + "message": "https://si.wikipedia.org/wiki/විශේෂ:ගවේෂණය" + }, + "searchForm": { + "message": "https://si.wikipedia.org/wiki/විශේෂ:ගවේෂණය" + }, + "suggestUrl": { + "message": "https://si.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/sk/messages.json b/browser/components/search/extensions/wikipedia/_locales/sk/messages.json new file mode 100644 index 0000000000..287d586a2b --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/sk/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipédia (sk)" + }, + "extensionDescription": { + "message": "Wikipédia, slobodná a otvorená encyklopédia" + }, + "searchUrl": { + "message": "https://sk.wikipedia.org/wiki/Špeciálne:Hľadanie" + }, + "searchForm": { + "message": "https://sk.wikipedia.org/wiki/Špeciálne:Hľadanie" + }, + "suggestUrl": { + "message": "https://sk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/sl/messages.json b/browser/components/search/extensions/wikipedia/_locales/sl/messages.json new file mode 100644 index 0000000000..ac3d13264e --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/sl/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedija (sl)" + }, + "extensionDescription": { + "message": "Wikipedija, prosta enciklopedija" + }, + "searchUrl": { + "message": "https://sl.wikipedia.org/wiki/Posebno:Iskanje" + }, + "searchForm": { + "message": "https://sl.wikipedia.org/wiki/Posebno:Iskanje" + }, + "suggestUrl": { + "message": "https://sl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/sq/messages.json b/browser/components/search/extensions/wikipedia/_locales/sq/messages.json new file mode 100644 index 0000000000..c7b1a581e7 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/sq/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (sq)" + }, + "extensionDescription": { + "message": "Wikipedia, enciklopedia e lirë" + }, + "searchUrl": { + "message": "https://sq.wikipedia.org/wiki/Speciale:Kërkim" + }, + "searchForm": { + "message": "https://sq.wikipedia.org/wiki/Speciale:Kërkim" + }, + "suggestUrl": { + "message": "https://sq.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/sr/messages.json b/browser/components/search/extensions/wikipedia/_locales/sr/messages.json new file mode 100644 index 0000000000..c457dcb9b0 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/sr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Википедија (sr)" + }, + "extensionDescription": { + "message": "Претрага Википедије на српском језику" + }, + "searchUrl": { + "message": "https://sr.wikipedia.org/wiki/Посебно:Претражи" + }, + "searchForm": { + "message": "https://sr.wikipedia.org/wiki/Посебно:Претражи" + }, + "suggestUrl": { + "message": "https://sr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/sv-SE/messages.json b/browser/components/search/extensions/wikipedia/_locales/sv-SE/messages.json new file mode 100644 index 0000000000..bcb18cf169 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/sv-SE/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (sv)" + }, + "extensionDescription": { + "message": "Wikipedia, den fria encyklopedin" + }, + "searchUrl": { + "message": "https://sv.wikipedia.org/wiki/Special:Sök" + }, + "searchForm": { + "message": "https://sv.wikipedia.org/wiki/Special:Sök" + }, + "suggestUrl": { + "message": "https://sv.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ta/messages.json b/browser/components/search/extensions/wikipedia/_locales/ta/messages.json new file mode 100644 index 0000000000..19bd1ccc9a --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ta/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "விக்கிப்பீடியா (ta)" + }, + "extensionDescription": { + "message": "விக்கிப்பீடியா (ta)" + }, + "searchUrl": { + "message": "https://ta.wikipedia.org/wiki/சிறப்பு:Search" + }, + "searchForm": { + "message": "https://ta.wikipedia.org/wiki/சிறப்பு:Search" + }, + "suggestUrl": { + "message": "https://ta.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/te/messages.json b/browser/components/search/extensions/wikipedia/_locales/te/messages.json new file mode 100644 index 0000000000..3fa0618eb8 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/te/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "వికీపీడియా (te)" + }, + "extensionDescription": { + "message": "వికీపీడియా (te)" + }, + "searchUrl": { + "message": "https://te.wikipedia.org/wiki/ప్రత్యేక:అన్వేషణ" + }, + "searchForm": { + "message": "https://te.wikipedia.org/wiki/ప్రత్యేక:అన్వేషణ" + }, + "suggestUrl": { + "message": "https://te.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/th/messages.json b/browser/components/search/extensions/wikipedia/_locales/th/messages.json new file mode 100644 index 0000000000..10f47248ba --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/th/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "วิกิพีเดีย" + }, + "extensionDescription": { + "message": "วิกิพีเดีย สารานุกรมเสรี" + }, + "searchUrl": { + "message": "https://th.wikipedia.org/wiki/พิเศษ:ค้นหา" + }, + "searchForm": { + "message": "https://th.wikipedia.org/wiki/พิเศษ:ค้นหา" + }, + "suggestUrl": { + "message": "https://th.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/tl/messages.json b/browser/components/search/extensions/wikipedia/_locales/tl/messages.json new file mode 100644 index 0000000000..05246341e9 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/tl/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (tl)" + }, + "extensionDescription": { + "message": "Wikipedia, ang malayang ensiklopedya" + }, + "searchUrl": { + "message": "https://tl.wikipedia.org/wiki/Natatangi:Maghanap" + }, + "searchForm": { + "message": "https://tl.wikipedia.org/wiki/Natatangi:Maghanap" + }, + "suggestUrl": { + "message": "https://tl.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/tr/messages.json b/browser/components/search/extensions/wikipedia/_locales/tr/messages.json new file mode 100644 index 0000000000..87d696f076 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/tr/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (tr)" + }, + "extensionDescription": { + "message": "Vikipedi, özgür ansiklopedi" + }, + "searchUrl": { + "message": "https://tr.wikipedia.org/wiki/Özel:Ara" + }, + "searchForm": { + "message": "https://tr.wikipedia.org/wiki/Özel:Ara" + }, + "suggestUrl": { + "message": "https://tr.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/uk/messages.json b/browser/components/search/extensions/wikipedia/_locales/uk/messages.json new file mode 100644 index 0000000000..842883e899 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/uk/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Вікіпедія (uk)" + }, + "extensionDescription": { + "message": "Вікіпедія, вільна енциклопедія" + }, + "searchUrl": { + "message": "https://uk.wikipedia.org/wiki/Спеціальна:Пошук" + }, + "searchForm": { + "message": "https://uk.wikipedia.org/wiki/Спеціальна:Пошук" + }, + "suggestUrl": { + "message": "https://uk.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/ur/messages.json b/browser/components/search/extensions/wikipedia/_locales/ur/messages.json new file mode 100644 index 0000000000..fe616805bf --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/ur/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "ویکیپیڈیا (ur)" + }, + "extensionDescription": { + "message": "ویکیپیڈیا آزاد دائرۃ المعارف" + }, + "searchUrl": { + "message": "https://ur.wikipedia.org/wiki/خاص:تلاش" + }, + "searchForm": { + "message": "https://ur.wikipedia.org/wiki/خاص:تلاش" + }, + "suggestUrl": { + "message": "https://ur.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/uz/messages.json b/browser/components/search/extensions/wikipedia/_locales/uz/messages.json new file mode 100644 index 0000000000..2be111e5f8 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/uz/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Vikipediya (uz)" + }, + "extensionDescription": { + "message": "Vikipediya, ochiq ensiklopediya" + }, + "searchUrl": { + "message": "https://uz.wikipedia.org/wiki/Maxsus:Search" + }, + "searchForm": { + "message": "https://uz.wikipedia.org/wiki/Maxsus:Search" + }, + "suggestUrl": { + "message": "https://uz.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/vi/messages.json b/browser/components/search/extensions/wikipedia/_locales/vi/messages.json new file mode 100644 index 0000000000..bc037299e6 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/vi/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (vi)" + }, + "extensionDescription": { + "message": "Wikipedia, bách khoa toàn thư mở" + }, + "searchUrl": { + "message": "https://vi.wikipedia.org/wiki/Đặc_biệt:Tìm_kiếm" + }, + "searchForm": { + "message": "https://vi.wikipedia.org/wiki/Đặc_biệt:Tìm_kiếm" + }, + "suggestUrl": { + "message": "https://vi.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/wo/messages.json b/browser/components/search/extensions/wikipedia/_locales/wo/messages.json new file mode 100644 index 0000000000..285764da13 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/wo/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (wo)" + }, + "extensionDescription": { + "message": "Wikipedia, Jimbulang bu Ubbeeku bi" + }, + "searchUrl": { + "message": "https://wo.wikipedia.org/wiki/Jagleel:Ceet" + }, + "searchForm": { + "message": "https://wo.wikipedia.org/wiki/Jagleel:Ceet" + }, + "suggestUrl": { + "message": "https://wo.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/zh-CN/messages.json b/browser/components/search/extensions/wikipedia/_locales/zh-CN/messages.json new file mode 100644 index 0000000000..5d5cd1be73 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/zh-CN/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "维基百科" + }, + "extensionDescription": { + "message": "维基百科,自由的百科全书" + }, + "searchUrl": { + "message": "https://zh.wikipedia.org/wiki/Special:搜索" + }, + "searchForm": { + "message": "https://zh.wikipedia.org/wiki/Special:搜索" + }, + "suggestUrl": { + "message": "https://zh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search" + } +} diff --git a/browser/components/search/extensions/wikipedia/_locales/zh-TW/messages.json b/browser/components/search/extensions/wikipedia/_locales/zh-TW/messages.json new file mode 100644 index 0000000000..401d14b619 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/_locales/zh-TW/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikipedia (zh)" + }, + "extensionDescription": { + "message": "維基百科,自由的百科全書" + }, + "searchUrl": { + "message": "https://zh.wikipedia.org/wiki/Special:搜索" + }, + "searchForm": { + "message": "https://zh.wikipedia.org/wiki/Special:搜索?variant=zh-tw" + }, + "suggestUrl": { + "message": "https://zh.wikipedia.org/w/api.php?action=opensearch&search={searchTerms}" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}&sourceid=Mozilla-search&variant=zh-tw" + } +} diff --git a/browser/components/search/extensions/wikipedia/favicon.ico b/browser/components/search/extensions/wikipedia/favicon.ico Binary files differnew file mode 100644 index 0000000000..4314071e24 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/favicon.ico diff --git a/browser/components/search/extensions/wikipedia/manifest.json b/browser/components/search/extensions/wikipedia/manifest.json new file mode 100644 index 0000000000..696d98fa60 --- /dev/null +++ b/browser/components/search/extensions/wikipedia/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.3", + "browser_specific_settings": { + "gecko": { + "id": "wikipedia@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "en", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": "@wikipedia", + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "suggest_url": "__MSG_suggestUrl__", + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/wiktionary/_locales/oc/messages.json b/browser/components/search/extensions/wiktionary/_locales/oc/messages.json new file mode 100644 index 0000000000..58367bf130 --- /dev/null +++ b/browser/components/search/extensions/wiktionary/_locales/oc/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "Wikiccionari (oc)" + }, + "extensionDescription": { + "message": "Wikiccionari, lo diccionari liure" + }, + "searchUrl": { + "message": "https://oc.wiktionary.org/wiki/Especial:Recèrca" + }, + "searchForm": { + "message": "https://oc.wiktionary.org/wiki/Especial:Recèrca" + }, + "suggestUrl": { + "message": "https://oc.wiktionary.org/w/api.php?action=opensearch&search={searchTerms}&namespace=0" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}" + } +} diff --git a/browser/components/search/extensions/wiktionary/_locales/te/messages.json b/browser/components/search/extensions/wiktionary/_locales/te/messages.json new file mode 100644 index 0000000000..19201032ff --- /dev/null +++ b/browser/components/search/extensions/wiktionary/_locales/te/messages.json @@ -0,0 +1,20 @@ +{ + "extensionName": { + "message": "విక్షనరీ (te)" + }, + "extensionDescription": { + "message": "విక్షనరీ (te)" + }, + "searchUrl": { + "message": "https://te.wiktionary.org/wiki/ప్రత్యేక:అన్వేషణ" + }, + "searchForm": { + "message": "https://te.wiktionary.org/wiki/ప్రత్యేక:అన్వేషణ" + }, + "suggestUrl": { + "message": "https://te.wiktionary.org/w/api.php?action=opensearch&search={searchTerms}&namespace=0" + }, + "searchUrlGetParams": { + "message": "search={searchTerms}" + } +} diff --git a/browser/components/search/extensions/wiktionary/favicon.ico b/browser/components/search/extensions/wiktionary/favicon.ico Binary files differnew file mode 100644 index 0000000000..31b0e38092 --- /dev/null +++ b/browser/components/search/extensions/wiktionary/favicon.ico diff --git a/browser/components/search/extensions/wiktionary/manifest.json b/browser/components/search/extensions/wiktionary/manifest.json new file mode 100644 index 0000000000..5301fdd1cc --- /dev/null +++ b/browser/components/search/extensions/wiktionary/manifest.json @@ -0,0 +1,26 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.2", + "browser_specific_settings": { + "gecko": { + "id": "wiktionary@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "oc", + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "suggest_url": "__MSG_suggestUrl__", + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/wolnelektury-pl/favicon.png b/browser/components/search/extensions/wolnelektury-pl/favicon.png Binary files differnew file mode 100644 index 0000000000..77f6db5322 --- /dev/null +++ b/browser/components/search/extensions/wolnelektury-pl/favicon.png diff --git a/browser/components/search/extensions/wolnelektury-pl/manifest.json b/browser/components/search/extensions/wolnelektury-pl/manifest.json new file mode 100644 index 0000000000..3599d9e82d --- /dev/null +++ b/browser/components/search/extensions/wolnelektury-pl/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Wolne Lektury", + "description": "Biblioteka internetowa WolneLektury.pl", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "wolnelektury-pl@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.png" + }, + "web_accessible_resources": ["favicon.png"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Wolne Lektury", + "search_url": "https://wolnelektury.pl/szukaj/?q={searchTerms}", + "search_form": "https://wolnelektury.pl", + "suggest_url": "https://wolnelektury.pl/katalog/jtags/?mozhint=1&q={searchTerms}" + } + } +} diff --git a/browser/components/search/extensions/yahoo-jp-auctions/favicon.ico b/browser/components/search/extensions/yahoo-jp-auctions/favicon.ico Binary files differnew file mode 100644 index 0000000000..4401c7a40e --- /dev/null +++ b/browser/components/search/extensions/yahoo-jp-auctions/favicon.ico diff --git a/browser/components/search/extensions/yahoo-jp-auctions/manifest.json b/browser/components/search/extensions/yahoo-jp-auctions/manifest.json new file mode 100644 index 0000000000..ea1a02f4ef --- /dev/null +++ b/browser/components/search/extensions/yahoo-jp-auctions/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "Yahoo!オークション", + "description": "ヤフオク! 検索", + "manifest_version": 2, + "version": "1.5", + "browser_specific_settings": { + "gecko": { + "id": "yahoo-jp-auctions@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Yahoo!オークション", + "encoding": "EUC-JP", + "search_url": "https://auctions.yahoo.co.jp/search/search", + "search_form": "https://auctions.yahoo.co.jp/", + "search_url_get_params": "p={searchTerms}&ei=EUC-JP&fr=mozff" + } + } +} diff --git a/browser/components/search/extensions/yahoo-jp/favicon.ico b/browser/components/search/extensions/yahoo-jp/favicon.ico Binary files differnew file mode 100644 index 0000000000..34a916ccde --- /dev/null +++ b/browser/components/search/extensions/yahoo-jp/favicon.ico diff --git a/browser/components/search/extensions/yahoo-jp/manifest.json b/browser/components/search/extensions/yahoo-jp/manifest.json new file mode 100644 index 0000000000..149c082af5 --- /dev/null +++ b/browser/components/search/extensions/yahoo-jp/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "Yahoo! JAPAN", + "description": "Yahoo Search", + "manifest_version": 2, + "version": "1.1", + "browser_specific_settings": { + "gecko": { + "id": "yahoo-jp@search.mozilla.org" + } + }, + "hidden": true, + "icons": { + "16": "favicon.ico" + }, + "web_accessible_resources": ["favicon.ico"], + "chrome_settings_overrides": { + "search_provider": { + "name": "Yahoo! JAPAN", + "search_url": "https://search.yahoo.co.jp/search", + "search_form": "https://search.yahoo.co.jp/", + "search_url_get_params": "p={searchTerms}&ei=UTF-8&fr=mozff" + } + } +} diff --git a/browser/components/search/extensions/yandex/_locales/az/messages.json b/browser/components/search/extensions/yandex/_locales/az/messages.json new file mode 100644 index 0000000000..d57aca3bb5 --- /dev/null +++ b/browser/components/search/extensions/yandex/_locales/az/messages.json @@ -0,0 +1,38 @@ +{ + "extensionName": { + "message": "Yandex" + }, + "extensionDescription": { + "message": "İnternetdə axtarış üçün Yandexdən istifadə edin." + }, + "searchUrl": { + "message": "https://yandex.az/search" + }, + "searchForm": { + "message": "https://www.yandex.az/" + }, + "suggestUrl": { + "message": "https://yandex.az/suggest/suggest-ff.cgi?part={searchTerms}" + }, + "searchUrlGetParams": { + "message": "text={searchTerms}" + }, + "param_searchbar": { + "message": "2186618" + }, + "param_keyword": { + "message": "2186621" + }, + "param_contextmenu": { + "message": "2186623" + }, + "param_homepage": { + "message": "2186617" + }, + "param_newtab": { + "message": "2186620" + }, + "extensionIcon": { + "message": "yandex-ru.ico" + } +} diff --git a/browser/components/search/extensions/yandex/_locales/by/messages.json b/browser/components/search/extensions/yandex/_locales/by/messages.json new file mode 100644 index 0000000000..0bcb41945e --- /dev/null +++ b/browser/components/search/extensions/yandex/_locales/by/messages.json @@ -0,0 +1,38 @@ +{ + "extensionName": { + "message": "Яндекс" + }, + "extensionDescription": { + "message": "Пошук з дапамогаю Яндекс" + }, + "searchUrl": { + "message": "https://yandex.by/search" + }, + "searchForm": { + "message": "https://www.yandex.by/" + }, + "suggestUrl": { + "message": "https://suggest.yandex.by/suggest-ff.cgi?part={searchTerms}" + }, + "searchUrlGetParams": { + "message": "text={searchTerms}" + }, + "param_searchbar": { + "message": "2186618" + }, + "param_keyword": { + "message": "2186621" + }, + "param_contextmenu": { + "message": "2186623" + }, + "param_homepage": { + "message": "2186617" + }, + "param_newtab": { + "message": "2186620" + }, + "extensionIcon": { + "message": "yandex-ru.ico" + } +} diff --git a/browser/components/search/extensions/yandex/_locales/en/messages.json b/browser/components/search/extensions/yandex/_locales/en/messages.json new file mode 100644 index 0000000000..ee7f914640 --- /dev/null +++ b/browser/components/search/extensions/yandex/_locales/en/messages.json @@ -0,0 +1,38 @@ +{ + "extensionName": { + "message": "Yandex" + }, + "extensionDescription": { + "message": "Use Yandex to search the Internet." + }, + "searchUrl": { + "message": "https://www.yandex.com/search" + }, + "searchForm": { + "message": "https://www.yandex.com/" + }, + "suggestUrl": { + "message": "https://suggest.yandex.com/suggest-ff.cgi?part={searchTerms}" + }, + "searchUrlGetParams": { + "message": "text={searchTerms}" + }, + "param_searchbar": { + "message": "2186618" + }, + "param_keyword": { + "message": "2186621" + }, + "param_contextmenu": { + "message": "2186623" + }, + "param_homepage": { + "message": "2186617" + }, + "param_newtab": { + "message": "2186620" + }, + "extensionIcon": { + "message": "yandex-en.ico" + } +} diff --git a/browser/components/search/extensions/yandex/_locales/kk/messages.json b/browser/components/search/extensions/yandex/_locales/kk/messages.json new file mode 100644 index 0000000000..c1e924d987 --- /dev/null +++ b/browser/components/search/extensions/yandex/_locales/kk/messages.json @@ -0,0 +1,38 @@ +{ + "extensionName": { + "message": "Яндекс" + }, + "extensionDescription": { + "message": "Воспользуйтесь Яндексом для поиска в Интернете." + }, + "searchUrl": { + "message": "https://yandex.kz/search" + }, + "searchForm": { + "message": "https://www.yandex.kz/" + }, + "suggestUrl": { + "message": "https://suggest.yandex.kz/suggest-ff.cgi?part={searchTerms}" + }, + "searchUrlGetParams": { + "message": "text={searchTerms}" + }, + "param_searchbar": { + "message": "2186618" + }, + "param_keyword": { + "message": "2186621" + }, + "param_contextmenu": { + "message": "2186623" + }, + "param_homepage": { + "message": "2186617" + }, + "param_newtab": { + "message": "2186620" + }, + "extensionIcon": { + "message": "yandex-ru.ico" + } +} diff --git a/browser/components/search/extensions/yandex/_locales/ru/messages.json b/browser/components/search/extensions/yandex/_locales/ru/messages.json new file mode 100644 index 0000000000..072370fd00 --- /dev/null +++ b/browser/components/search/extensions/yandex/_locales/ru/messages.json @@ -0,0 +1,38 @@ +{ + "extensionName": { + "message": "Яндекс" + }, + "extensionDescription": { + "message": "Воспользуйтесь Яндексом для поиска в Интернете." + }, + "searchUrl": { + "message": "https://yandex.ru/search" + }, + "searchForm": { + "message": "https://www.yandex.ru/" + }, + "suggestUrl": { + "message": "https://suggest.yandex.ru/suggest-ff.cgi?part={searchTerms}" + }, + "searchUrlGetParams": { + "message": "text={searchTerms}" + }, + "param_searchbar": { + "message": "2186618" + }, + "param_keyword": { + "message": "2186621" + }, + "param_contextmenu": { + "message": "2186623" + }, + "param_homepage": { + "message": "2186617" + }, + "param_newtab": { + "message": "2186620" + }, + "extensionIcon": { + "message": "yandex-ru.ico" + } +} diff --git a/browser/components/search/extensions/yandex/_locales/tr/messages.json b/browser/components/search/extensions/yandex/_locales/tr/messages.json new file mode 100644 index 0000000000..35b4a44bae --- /dev/null +++ b/browser/components/search/extensions/yandex/_locales/tr/messages.json @@ -0,0 +1,38 @@ +{ + "extensionName": { + "message": "Yandex" + }, + "extensionDescription": { + "message": "Yandex Türkiye arama motoru" + }, + "searchUrl": { + "message": "https://yandex.com.tr/search" + }, + "searchForm": { + "message": "https://www.yandex.com.tr/" + }, + "suggestUrl": { + "message": "https://suggest.yandex.com.tr/suggest-ff.cgi?part={searchTerms}" + }, + "searchUrlGetParams": { + "message": "text={searchTerms}" + }, + "param_searchbar": { + "message": "2186618" + }, + "param_keyword": { + "message": "2186621" + }, + "param_contextmenu": { + "message": "2186623" + }, + "param_homepage": { + "message": "2186617" + }, + "param_newtab": { + "message": "2186620" + }, + "extensionIcon": { + "message": "yandex-en.ico" + } +} diff --git a/browser/components/search/extensions/yandex/_locales/ua/messages.json b/browser/components/search/extensions/yandex/_locales/ua/messages.json new file mode 100644 index 0000000000..6d7efc2848 --- /dev/null +++ b/browser/components/search/extensions/yandex/_locales/ua/messages.json @@ -0,0 +1,23 @@ +{ + "extensionName": { + "message": "Яндекс" + }, + "extensionDescription": { + "message": "Воспользуйтесь Яндексом для поиска в Интернете." + }, + "searchUrl": { + "message": "https://yandex.ua/yandsearch" + }, + "searchForm": { + "message": "https://www.yandex.ua/" + }, + "suggestUrl": { + "message": "https://suggest.yandex.ua/suggest-ff.cgi?part={searchTerms}" + }, + "searchUrlGetParams": { + "message": "text={searchTerms}" + }, + "extensionIcon": { + "message": "yandex-ru.ico" + } +} diff --git a/browser/components/search/extensions/yandex/manifest.json b/browser/components/search/extensions/yandex/manifest.json new file mode 100644 index 0000000000..0d609a9019 --- /dev/null +++ b/browser/components/search/extensions/yandex/manifest.json @@ -0,0 +1,59 @@ +{ + "name": "__MSG_extensionName__", + "description": "__MSG_extensionDescription__", + "manifest_version": 2, + "version": "1.3", + "browser_specific_settings": { + "gecko": { + "id": "yandex@search.mozilla.org" + } + }, + "hidden": true, + "default_locale": "en", + "icons": { + "16": "__MSG_extensionIcon__" + }, + "web_accessible_resources": ["yandex-en.ico", "yandex-ru.ico"], + "chrome_settings_overrides": { + "search_provider": { + "keyword": ["@\u044F\u043D\u0434\u0435\u043A\u0441", "@yandex"], + "name": "__MSG_extensionName__", + "search_url": "__MSG_searchUrl__", + "search_form": "__MSG_searchForm__", + "suggest_url": "__MSG_suggestUrl__", + "params": [ + { + "name": "clid", + "condition": "purpose", + "purpose": "searchbar", + "value": "__MSG_param_searchbar__" + }, + { + "name": "clid", + "condition": "purpose", + "purpose": "keyword", + "value": "__MSG_param_keyword__" + }, + { + "name": "clid", + "condition": "purpose", + "purpose": "contextmenu", + "value": "__MSG_param_contextmenu__" + }, + { + "name": "clid", + "condition": "purpose", + "purpose": "homepage", + "value": "__MSG_param_homepage__" + }, + { + "name": "clid", + "condition": "purpose", + "purpose": "newtab", + "value": "__MSG_param_newtab__" + } + ], + "search_url_get_params": "__MSG_searchUrlGetParams__" + } + } +} diff --git a/browser/components/search/extensions/yandex/yandex-en.ico b/browser/components/search/extensions/yandex/yandex-en.ico Binary files differnew file mode 100644 index 0000000000..d1c3f3f8b1 --- /dev/null +++ b/browser/components/search/extensions/yandex/yandex-en.ico diff --git a/browser/components/search/extensions/yandex/yandex-ru.ico b/browser/components/search/extensions/yandex/yandex-ru.ico Binary files differnew file mode 100644 index 0000000000..eb187398c7 --- /dev/null +++ b/browser/components/search/extensions/yandex/yandex-ru.ico diff --git a/browser/components/search/jar.mn b/browser/components/search/jar.mn new file mode 100644 index 0000000000..a9617478d2 --- /dev/null +++ b/browser/components/search/jar.mn @@ -0,0 +1,13 @@ +# 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/. + +browser.jar: + content/browser/search/autocomplete-popup.js (content/autocomplete-popup.js) + content/browser/search/searchbar.js (content/searchbar.js) + content/browser/contentSearchUI.js (content/contentSearchUI.js) + content/browser/contentSearchHandoffUI.js (content/contentSearchHandoffUI.js) + content/browser/contentSearchUI.css (content/contentSearchUI.css) + search-extensions/ (extensions/**) + +% resource search-extensions %search-extensions/ contentaccessible=yes diff --git a/browser/components/search/metrics.yaml b/browser/components/search/metrics.yaml new file mode 100644 index 0000000000..4faff64e3e --- /dev/null +++ b/browser/components/search/metrics.yaml @@ -0,0 +1,355 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - 'Firefox :: Search' + +newtab.search: + issued: + type: event + description: > + When Firefox was asked to issue a search from a Search Access Point (SAP) + on a newtab page. + Doesn't record searches in Private Browsing Mode unless + `browser.engagement.search_counts.pbm` is set to `true`. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: &newtab_visit_id + description: > + The id of the newtab visit that originated the search. + Should always be present for handoff searches. + TODO(bug 1774597): for searches done without handoff (e.g. with + `browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar` + set to `false`), the active newtab visit id is unknown. + type: string + search_access_point: &search_access_point + description: > + One of the search access points available on the new tab like + * `urlbar_handoff` + * `about_home` + * `about_newtab` + type: string + telemetry_id: &telemetry_id + description: > + The search engine's `telemetryId`, like `google-b-d`. + This is set to be a telemetry-specific id for app-provided engines, + and is `other-<name>` for others (where `<name>` is the engine's + WebExtension name). + type: string + send_in_pings: + - newtab + +newtab.search.ad: + impression: + type: event + description: > + Recorded when a newtab visit resulted in a search that + loaded a Search Engine Result Page (SERP) that contains an ad link. + And the SERP is visible. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + search_access_point: *search_access_point + is_follow_on: &is_follow_on + description: > + Whether the preceding search happened on a search results page. + type: boolean + is_tagged: &is_tagged + description: > + Whether the preceding search was tagged with a partner code. + type: boolean + telemetry_id: *telemetry_id + send_in_pings: + - newtab + + click: + type: event + description: > + Recorded when an ad link is clicked on a Search Engine Result Page (SERP) + which was loaded by a seach that began on a newtab page. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1766887 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1786670#c3 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1817105#c11 + data_sensitivity: + - interaction + notification_emails: + - anicholson@mozilla.com + - chutten@mozilla.com + - mmccorquodale@mozilla.com + - najiang@mozilla.com + - lina@mozilla.com + expires: never + extra_keys: + newtab_visit_id: *newtab_visit_id + search_access_point: *search_access_point + is_follow_on: *is_follow_on + is_tagged: *is_tagged + telemetry_id: *telemetry_id + send_in_pings: + - newtab + +serp: + impression: + type: event + description: > + Recorded when a search engine results page (SERP) is shown to a user. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1813162 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1824543 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816736 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816738 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1829953 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851495 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1813162 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1824543 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1851495 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + extra_keys: + impression_id: &impression_id + description: > + A uuid to link SERP events to user's engagement events. + type: string + provider: + description: > + The name of the provider. + type: string + tagged: + description: > + Whether the search is tagged (true) or organic (false). + type: boolean + partner_code: + description: > + Any partner_code parsing in the URL or an empty string if not + available. + type: string + source: + description: > + How the user arrived at the SERP. + Possible values are: + `urlbar`, `urlbar_handoff`, `urlbar_searchmode`, `urlbar_persisted`, + `searchbar`, `contextmenu`, `webextension`, `system`, `reload`, + `tabhistory`, `follow_on_from_refine_on_incontent_search`, + `follow_on_from_refine_on_SERP`, `opened_in_new_tab`, `unknown`. + This will be `unknown` if we cannot determine the source. + type: string + shopping_tab_displayed: + description: + Indicates if the shopping tab is displayed. + type: boolean + is_shopping_page: + description: + Indicates if the page is a shopping page. + type: boolean + is_private: + description: + Indicates if the page was loaded while in Private Browsing Mode. + type: boolean + + engagement: + type: event + description: > + Recorded user actions on a SERP. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1814773 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816730 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816735 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1814773 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816730 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + extra_keys: + impression_id: *impression_id + action: + description: > + The action taken on the page. + Possible values are `clicked`, `expanded`, and `submitted`. + type: string + target: + description: > + The target component used to trigger the action. + Possible values are: + `ad_carousel`, + `ad_image_row`, + `ad_link`, + `ad_sidebar`, + `ad_sitelink`, + `incontent_searchbox`, + `non_ads_link`, + `refined_search_buttons`, + `shopping_tab`. + type: string + + ad_impression: + type: event + description: > + Recorded when a user loads a SERP and ads are detected. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816728 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816729 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1816728 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + extra_keys: + impression_id: *impression_id + component: + description: > + Type of components on a SERP. Possible values are: + `ad_carousel`, + `ad_image_row`, + `ad_link`, + `ad_sidebar`, + `ad_sitelink`, + `refined_search_buttons`, + `shopping_tab`. + Defaults to `ad_link`. + type: string + ads_loaded: + description: > + Number of ads loaded for this component. They may or + may not be visible on the page. + type: quantity + ads_visible: + description: > + Number of ads visible for this component. An ad can be + considered visible if was within the browser window + by the time the impression was recorded. + type: quantity + ads_hidden: + description: > + Number of ads hidden for this component. These are ads that + are loaded in the DOM but hidden via CSS and/or Javascript. + type: quantity + + abandonment: + type: event + description: > + Recorded when there is no engagement with the SERP before the tab is + closed, the window is closed, the app is closed, or the tab is navigated + away from. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1814776 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1814776 + data_sensitivity: + - interaction + notification_emails: + - fx-search-telemetry@mozilla.com + - rev-data@mozilla.com + expires: never + extra_keys: + impression_id: *impression_id + reason: + description: > + Why the SERP is deemed abandoned. + Possible values are: + `tab_close`, `window_close`, `navigation` + type: string + + categorization_duration: + type: timing_distribution + time_unit: millisecond + description: > + The time it takes to categorize elements on a SERP. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1834100 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1834100 + data_sensitivity: + - technical + notification_emails: + - fx-search-telemetry@mozilla.com + expires: never + +search_with: + reporting_url: + type: url + description: > + The external url to report this interaction to. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1870138 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1870138 + data_sensitivity: + - web_activity + notification_emails: + - mkaply@mozilla.com + expires: never + send_in_pings: + - search-with + + context_id: + type: uuid + description: > + An identifier for Contextual Services user interaction pings. This is + used internally for counting unique users as well as for anti-fraud. It + is shared with other Contextual Services. It is not shared externally. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1870138 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1870138#c3 + data_sensitivity: + - technical + notification_emails: + - mkaply@mozilla.com + expires: never + send_in_pings: + - search-with diff --git a/browser/components/search/moz.build b/browser/components/search/moz.build new file mode 100644 index 0000000000..9f89090aa2 --- /dev/null +++ b/browser/components/search/moz.build @@ -0,0 +1,29 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES += [ + "BrowserSearchTelemetry.sys.mjs", + "SearchOneOffs.sys.mjs", + "SearchSERPTelemetry.sys.mjs", + "SearchUIUtils.sys.mjs", +] + +BROWSER_CHROME_MANIFESTS += [ + "test/browser/browser.toml", + "test/browser/google_codes/browser.toml", + "test/browser/telemetry/browser.toml", +] + +MARIONETTE_MANIFESTS += ["test/marionette/manifest.toml"] + +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] + +JAR_MANIFESTS += ["jar.mn"] + +SPHINX_TREES["/browser/search"] = "docs" + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Search") diff --git a/browser/components/search/pings.yaml b/browser/components/search/pings.yaml new file mode 100644 index 0000000000..727204e3fa --- /dev/null +++ b/browser/components/search/pings.yaml @@ -0,0 +1,22 @@ +# 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/. + +--- +$schema: moz://mozilla.org/schemas/glean/pings/2-0-0 + +search-with: + description: | + A ping representing a "This time, search with" event with a partner search. + Does not contain a `client_id`, preferring a `context_id` instead. + The `context_id` is used internally for counting unique sers as well as for + anti-fraud. It is shared with other Contextual Services. It is not shared + externally. + + include_client_id: false + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1870138 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1870138 + notification_emails: + - mkaply@mozilla.com diff --git a/browser/components/search/schema/Readme.txt b/browser/components/search/schema/Readme.txt new file mode 100644 index 0000000000..14fffb5c10 --- /dev/null +++ b/browser/components/search/schema/Readme.txt @@ -0,0 +1,7 @@ +The schemas in this directory are the primary source for the schemas they represent. + +They are uploaded to the RemoteSettings server to validate new configurations. + +Any changes should be validated by the Search team. + +See the documentation for more information: https://firefox-source-docs.mozilla.org/ diff --git a/browser/components/search/schema/search-telemetry-schema.json b/browser/components/search/schema/search-telemetry-schema.json new file mode 100644 index 0000000000..b985ae0802 --- /dev/null +++ b/browser/components/search/schema/search-telemetry-schema.json @@ -0,0 +1,417 @@ +{ + "type": "object", + "required": [ + "telemetryId", + "searchPageRegexp", + "queryParamName", + "queryParamNames" + ], + "properties": { + "telemetryId": { + "type": "string", + "title": "Telemetry Id", + "description": "The telemetry identifier for the provider.", + "pattern": "^[a-z0-9-._]*$" + }, + "searchPageMatches": { + "type": "array", + "title": "Search Page Matches", + "description": "An array containing match expressions used to match on URLs.", + "items": { + "type": "string" + } + }, + "searchPageRegexp": { + "type": "string", + "title": "Search Page Regular Expression", + "description": "A regular expression which matches the search page of the provider." + }, + "queryParamName": { + "type": "string", + "title": "Search Query Parameter Name", + "description": "The name of the query parameter for the user's search string. This is deprecated, in preference to queryParamNames, but still defined for older clients (pre Firefox 121)." + }, + "queryParamNames": { + "type": "array", + "title": "Search Query Parameter Names", + "description": "An array of query parameters that may be used for the user's search string.", + "items": { + "type": "string" + } + }, + "codeParamName": { + "type": "string", + "title": "Partner Code Parameter Name", + "description": "The name of the query parameter for the partner code." + }, + "taggedCodes": { + "type": "array", + "title": "Partner Codes", + "description": "An array of partner codes to match against the parameters in the url. Matching these codes will report the SERP as tagged.", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9-._]*$" + } + }, + "expectedOrganicCodes": { + "type": "array", + "title": "Expected Organic Codes", + "description": "An array of partner codes to match against the parameters in the url. Matching these codes will report the SERP as organic:none which means the user has done a search through the search engine's website rather than through SAP.", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9-._]*$" + } + }, + "organicCodes": { + "type": "array", + "title": "Organic Codes", + "description": "An array of partner codes to match against the parameters in the url. Matching these codes will report the SERP as organic:<partner code>, which means the search was performed organically rather than through a SAP.", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9-._]*$" + } + }, + "followOnParamNames": { + "type": "array", + "title": "Follow-on Search Parameter Names", + "description": "An array of query parameter names that are used when a follow-on search occurs.", + "items": { + "type": "string", + "pattern": "^[a-z0-9-._]*$" + } + }, + "followOnCookies": { + "type": "array", + "title": "Follow-on Cookies", + "description": "An array of cookie details that are used to identify follow-on searches.", + "items": { + "type": "object", + "properties": { + "extraCodeParamName": { + "type": "string", + "description": "The query parameter name in the URL that indicates this might be a follow-on search.", + "pattern": "^[a-z0-9-._]*$" + }, + "extraCodePrefixes": { + "type": "array", + "description": "Possible values for the query parameter in the URL that indicates this might be a follow-on search.", + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9-._]*$" + } + }, + "host": { + "type": "string", + "description": "The hostname on which the cookie is stored.", + "pattern": "^[a-z0-9-._]*$" + }, + "name": { + "type": "string", + "description": "The name of the cookie to check.", + "pattern": "^[a-zA-Z0-9-._]*$" + }, + "codeParamName": { + "type": "string", + "description": "The name of parameter within the cookie.", + "pattern": "^[a-zA-Z0-9-._]*$" + } + } + } + }, + "extraAdServersRegexps": { + "type": "array", + "title": "Extra Ad Server Regular Expressions", + "description": "An array of regular expressions that match URLs of potential ad servers.", + "items": { + "type": "string" + } + }, + "adServerAttributes": { + "type": "array", + "title": "Ad Server Attributes", + "description": "An array of strings that potentially match data-attribute keys of anchors.", + "items": { + "type": "string" + } + }, + "components": { + "type": "array", + "title": "Components", + "description": "An array of components that could be on the SERP.", + "items": { + "required": ["type"], + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The type of component the anchor or DOM element should belong to.", + "pattern": "^[a-z](?:_?[a-z])*$" + }, + "included": { + "type": "object", + "description": "Conditions that should be fulfilled.", + "properties": { + "parent": { + "title": "Parent", + "description": "The DOM element that should only contain elements applicable to a single component type.", + "type": "object", + "properties": { + "selector": { + "description": "If topDown is true for this component, then this will be the value used in querySelectorAll(). Otherwise, it will be the value to in closest() from the context of an anchor.", + "type": "string" + }, + "eventListeners": { + "$ref": "#/definitions/eventListeners" + }, + "skipCount": { + "$ref": "#/definitions/skipCount" + } + }, + "required": ["selector"] + }, + "children": { + "type": "array", + "title": "Children", + "description": "Child DOM elements of the parent. Optional.", + "items": { + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "The selector to use querySelectorAll from the context of the parent." + }, + "type": { + "type": "string", + "description": "The component type to use if this child is present.", + "pattern": "^[a-z](?:_?[a-z])*$" + }, + "countChildren": { + "type": "boolean", + "description": "Whether we should count all instances of the child element instead of anchor links found inside of the parent. Defaults to false." + }, + "eventListeners": { + "$ref": "#/definitions/eventListeners" + }, + "skipCount": { + "$ref": "#/definitions/skipCount" + } + }, + "required": ["selector"] + } + }, + "related": { + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "The selector to use querySelectorAll from the context of the parent. Any elements specified will have their click events registered and categorized as expanded unless explicitly overwritten in SearchSERPTelemetryChild." + } + }, + "required": ["selector"] + } + }, + "required": ["parent"] + }, + "excluded": { + "type": "object", + "description": "Conditions that should not be included.", + "properties": { + "parent": { + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "The root DOM element that shouldn't be a parent from the context of the anchor being inspected." + } + }, + "required": ["selector"] + } + } + }, + "default": { + "type": "boolean", + "description": "Whether this component should be the fallback option if a link was included in both ad-related regular expressions as well as regular expressions matching non-ad elements but couldn't be categorized. Defaults to false." + }, + "topDown": { + "type": "boolean", + "description": "Whether the component should be found first by using document.querySelectorAll on the parent selector definition. Defaults to false." + }, + "dependentRequired": { + "topDown": ["included"] + } + } + } + }, + "ignoreLinkRegexps": { + "type": "array", + "title": "Ignore links matching regular expressions", + "description": "Regular expressions matching links that should be ignored by the network observer.", + "items": { + "type": "string", + "description": "The matching regular expression." + } + }, + "nonAdsLinkRegexps": { + "type": "array", + "title": "Non-ads link matching regular expressions", + "description": "An array containing known patterns that match non-ad links from a search provider.", + "items": { + "type": "string", + "description": "The matching regular expression." + } + }, + "shoppingTab": { + "type": "object", + "title": "Shopping Tab", + "properties": { + "selector": { + "type": "string", + "description": "The elements on the page to inspect for the shopping tab. Should be anchor elements." + }, + "regexp": { + "type": "string", + "description": "The regular expression to match against a possible shopping tab. Must be provided if using this feature." + }, + "inspectRegexpInSERP": { + "type": "boolean", + "description": "Whether the regexp should be used against hrefs the selector matches against." + } + }, + "required": ["selector", "regexp"] + }, + "domainExtraction": { + "type": "object", + "title": "Domain Extraction", + "description": "An array of methods for extracting domains from a SERP result.", + "properties": { + "ads": { + "type": "array", + "description": "An array of methods for extracting domains from ads.", + "items": { + "$ref": "#/definitions/extraction" + } + }, + "nonAds": { + "type": "array", + "description": "An array of methods for extracting domains from non-ads.", + "items": { + "$ref": "#/definitions/extraction" + } + } + } + }, + "isSPA": { + "type": "boolean", + "title": "Is Single Page App", + "description": "Whether the provider exhibits tendencies of a single page app, namely changes the entire contents of the page without having to reload." + }, + "defaultPageQueryParam": { + "type": "object", + "title": "Default page query parameter", + "properties": { + "key": { + "type": "string", + "description": "The key corresponding to the query parameter that contains what type of search page is being shown." + }, + "value": { + "type": "string", + "description": "The value corresponding to the query parameter that should be matched against." + } + }, + "required": ["key", "value"] + } + }, + "definitions": { + "eventListener": { + "title": "Event Listener", + "type": "object", + "description": "Event listeners attached to a component.", + "properties": { + "eventType": { + "title": "Event Type", + "description": "The type of event to listen for. Custom events, especially those with special logic like keydownEnter, can be used if the Desktop code has been updated.", + "type": "string", + "pattern": "^[a-z][A-Za-z]*$" + }, + "target": { + "title": "Target", + "description": "The component type to report when the event is triggered. Uses the child component type (if exists), otherwise uses the parent component type.", + "type": "string", + "pattern": "^[a-z](?:_?[a-z])*$" + }, + "action": { + "title": "Action", + "description": "The action to report when the event is triggered. If the event type is 'click', defaults to clicked. Otherwise, this should be provided.", + "type": "string", + "pattern": "^[a-z](?:_?[a-z])*$" + } + }, + "required": ["eventType"] + }, + "eventListeners": { + "title": "Event Listeners", + "description": "An array of Event Listeners to apply to elements.", + "type": "array", + "items": { + "$ref": "#/definitions/eventListener" + } + }, + "extraction": { + "anyOf": [ + { + "type": "object", + "properties": { + "selectors": { + "type": "string", + "description": "The query to inspect all elements on the SERP." + }, + "method": { + "enum": ["data-attribute"], + "description": "The extraction method used for the query." + }, + "options": { + "type": "object", + "properties": { + "dataAttributeKey": { + "type": "string", + "description": "The data attribute key that will be looked up in order to retrieve its data attribute value." + } + }, + "required": ["dataAttributeKey"] + } + }, + "required": ["selectors", "method", "options"] + }, + { + "type": "object", + "properties": { + "selectors": { + "type": "string", + "description": "The query to use to inspect all elements on the SERP." + }, + "method": { + "enum": ["href"], + "description": "The extraction method to use for the query." + }, + "options": { + "type": "object", + "properties": { + "queryParamKey": { + "type": "string", + "description": "The query parameter key to inspect in the href." + } + }, + "required": ["queryParamKey"] + } + }, + "required": ["selectors", "method"] + } + ] + } + }, + "skipCount": { + "title": "Skip Count", + "description": "Whether to skip reporting of the count of these elements to ad_impressions. Defaults to false.", + "type": "boolean" + } +} diff --git a/browser/components/search/schema/search-telemetry-ui-schema.json b/browser/components/search/schema/search-telemetry-ui-schema.json new file mode 100644 index 0000000000..781da5a626 --- /dev/null +++ b/browser/components/search/schema/search-telemetry-ui-schema.json @@ -0,0 +1,23 @@ +{ + "ui:order": [ + "telemetryId", + "searchPageMatches", + "searchPageRegexp", + "queryParamNames", + "queryParamName", + "codeParamName", + "taggedCodes", + "expectedOrganicCodes", + "organicCodes", + "followOnParamNames", + "followOnCookies", + "extraAdServersRegexps", + "adServerAttributes", + "components", + "nonAdsLinkRegexps", + "shoppingTab", + "domainExtraction", + "isSPA", + "defaultPageQueryParam" + ] +} 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"] |