summaryrefslogtreecommitdiffstats
path: root/browser/components/search
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /browser/components/search
parentInitial commit. (diff)
downloadfirefox-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')
-rw-r--r--browser/components/search/.eslintrc.js13
-rw-r--r--browser/components/search/BrowserSearchTelemetry.sys.mjs328
-rw-r--r--browser/components/search/SearchOneOffs.sys.mjs1126
-rw-r--r--browser/components/search/SearchSERPTelemetry.sys.mjs2515
-rw-r--r--browser/components/search/SearchUIUtils.sys.mjs120
-rw-r--r--browser/components/search/content/autocomplete-popup.js289
-rw-r--r--browser/components/search/content/contentSearchHandoffUI.js152
-rw-r--r--browser/components/search/content/contentSearchUI.css160
-rw-r--r--browser/components/search/content/contentSearchUI.js1021
-rw-r--r--browser/components/search/content/searchbar.js907
-rw-r--r--browser/components/search/docs/Preferences.rst25
-rw-r--r--browser/components/search/docs/application-search-engines.rst41
-rw-r--r--browser/components/search/docs/index.rst23
-rw-r--r--browser/components/search/docs/telemetry.rst201
-rw-r--r--browser/components/search/extensions/1und1/favicon.icobin0 -> 159 bytes
-rw-r--r--browser/components/search/extensions/1und1/manifest.json24
-rw-r--r--browser/components/search/extensions/allegro-pl/favicon.icobin0 -> 1150 bytes
-rw-r--r--browser/components/search/extensions/allegro-pl/manifest.json24
-rw-r--r--browser/components/search/extensions/amazon/_locales/au/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/ca/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/de/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/en-GB/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/france/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/in/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/it/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/jp/messages.json23
-rw-r--r--browser/components/search/extensions/amazon/_locales/nl/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/spain/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/_locales/sweden/messages.json17
-rw-r--r--browser/components/search/extensions/amazon/favicon.icobin0 -> 1407 bytes
-rw-r--r--browser/components/search/extensions/amazon/manifest.json26
-rw-r--r--browser/components/search/extensions/amazondotcn/_locales/default/messages.json8
-rw-r--r--browser/components/search/extensions/amazondotcn/_locales/mozillaonline/messages.json8
-rw-r--r--browser/components/search/extensions/amazondotcn/favicon.icobin0 -> 1407 bytes
-rw-r--r--browser/components/search/extensions/amazondotcn/manifest.json26
-rw-r--r--browser/components/search/extensions/amazondotcom/_locales/en/messages.json17
-rw-r--r--browser/components/search/extensions/amazondotcom/_locales/us/messages.json17
-rw-r--r--browser/components/search/extensions/amazondotcom/favicon.icobin0 -> 1407 bytes
-rw-r--r--browser/components/search/extensions/amazondotcom/manifest.json26
-rw-r--r--browser/components/search/extensions/azerdict/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/azerdict/manifest.json24
-rw-r--r--browser/components/search/extensions/baidu/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/baidu/manifest.json27
-rw-r--r--browser/components/search/extensions/bing/favicon.icobin0 -> 4286 bytes
-rw-r--r--browser/components/search/extensions/bing/manifest.json59
-rw-r--r--browser/components/search/extensions/bok-NO/favicon.pngbin0 -> 530 bytes
-rw-r--r--browser/components/search/extensions/bok-NO/manifest.json24
-rw-r--r--browser/components/search/extensions/ceneji/favicon.pngbin0 -> 283 bytes
-rw-r--r--browser/components/search/extensions/ceneji/manifest.json24
-rw-r--r--browser/components/search/extensions/coccoc/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/coccoc/manifest.json25
-rw-r--r--browser/components/search/extensions/daum-kr/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/daum-kr/manifest.json26
-rw-r--r--browser/components/search/extensions/ddg/favicon.icobin0 -> 2799 bytes
-rw-r--r--browser/components/search/extensions/ddg/manifest.json27
-rw-r--r--browser/components/search/extensions/ebay/_locales/at/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/au/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/be/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/ca/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/ch/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/de/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/en/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/es/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/fr/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/ie/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/it/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/nl/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/_locales/uk/messages.json20
-rw-r--r--browser/components/search/extensions/ebay/favicon.icobin0 -> 1455 bytes
-rw-r--r--browser/components/search/extensions/ebay/manifest.json28
-rw-r--r--browser/components/search/extensions/ecosia/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/ecosia/manifest.json26
-rw-r--r--browser/components/search/extensions/eudict/favicon.icobin0 -> 1785 bytes
-rw-r--r--browser/components/search/extensions/eudict/manifest.json24
-rw-r--r--browser/components/search/extensions/faclair-beag/favicon.icobin0 -> 1091 bytes
-rw-r--r--browser/components/search/extensions/faclair-beag/manifest.json23
-rw-r--r--browser/components/search/extensions/gmx/_locales/de/messages.json17
-rw-r--r--browser/components/search/extensions/gmx/_locales/en-GB/messages.json17
-rw-r--r--browser/components/search/extensions/gmx/_locales/es/messages.json17
-rw-r--r--browser/components/search/extensions/gmx/_locales/fr/messages.json17
-rw-r--r--browser/components/search/extensions/gmx/_locales/shopping/messages.json17
-rw-r--r--browser/components/search/extensions/gmx/favicon.pngbin0 -> 1122 bytes
-rw-r--r--browser/components/search/extensions/gmx/manifest.json26
-rw-r--r--browser/components/search/extensions/google/_locales/en/messages.json23
-rw-r--r--browser/components/search/extensions/google/_locales/region-by/messages.json20
-rw-r--r--browser/components/search/extensions/google/_locales/region-kz/messages.json20
-rw-r--r--browser/components/search/extensions/google/_locales/region-ru/messages.json20
-rw-r--r--browser/components/search/extensions/google/_locales/region-tr/messages.json20
-rw-r--r--browser/components/search/extensions/google/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/google/manifest.json34
-rw-r--r--browser/components/search/extensions/gulesider-NO/favicon.icobin0 -> 1150 bytes
-rw-r--r--browser/components/search/extensions/gulesider-NO/manifest.json24
-rw-r--r--browser/components/search/extensions/leo_ende_de/favicon.pngbin0 -> 749 bytes
-rw-r--r--browser/components/search/extensions/leo_ende_de/manifest.json25
-rw-r--r--browser/components/search/extensions/longdo/favicon.icobin0 -> 252 bytes
-rw-r--r--browser/components/search/extensions/longdo/manifest.json26
-rw-r--r--browser/components/search/extensions/mailcom/favicon.icobin0 -> 1150 bytes
-rw-r--r--browser/components/search/extensions/mailcom/manifest.json25
-rw-r--r--browser/components/search/extensions/mailru/_locales/default/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/mailru001/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-az/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-en-US/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-hy-AM/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-kk/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-ro/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-ru/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-tr/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-uk/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/_locales/okru-uz/messages.json11
-rw-r--r--browser/components/search/extensions/mailru/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/mailru/manifest.json27
-rw-r--r--browser/components/search/extensions/mapy-cz/favicon.icobin0 -> 1812 bytes
-rw-r--r--browser/components/search/extensions/mapy-cz/manifest.json24
-rw-r--r--browser/components/search/extensions/mercadolibre/_locales/ar/messages.json17
-rw-r--r--browser/components/search/extensions/mercadolibre/_locales/cl/messages.json17
-rw-r--r--browser/components/search/extensions/mercadolibre/_locales/mx/messages.json17
-rw-r--r--browser/components/search/extensions/mercadolibre/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/mercadolibre/manifest.json25
-rw-r--r--browser/components/search/extensions/mercadolivre/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/mercadolivre/manifest.json24
-rw-r--r--browser/components/search/extensions/naver-kr/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/naver-kr/manifest.json26
-rw-r--r--browser/components/search/extensions/odpiralni/favicon.pngbin0 -> 2639 bytes
-rw-r--r--browser/components/search/extensions/odpiralni/manifest.json23
-rw-r--r--browser/components/search/extensions/pazaruvaj/favicon.icobin0 -> 2584 bytes
-rw-r--r--browser/components/search/extensions/pazaruvaj/manifest.json24
-rw-r--r--browser/components/search/extensions/priberam/favicon.pngbin0 -> 790 bytes
-rw-r--r--browser/components/search/extensions/priberam/manifest.json25
-rw-r--r--browser/components/search/extensions/prisjakt-sv-SE/favicon.icobin0 -> 1406 bytes
-rw-r--r--browser/components/search/extensions/prisjakt-sv-SE/manifest.json26
-rw-r--r--browser/components/search/extensions/qwant/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/qwant/manifest.json26
-rw-r--r--browser/components/search/extensions/qwantjr/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/qwantjr/manifest.json25
-rw-r--r--browser/components/search/extensions/rakuten/favicon.icobin0 -> 2053 bytes
-rw-r--r--browser/components/search/extensions/rakuten/manifest.json25
-rw-r--r--browser/components/search/extensions/readmoo/favicon.icobin0 -> 2468 bytes
-rw-r--r--browser/components/search/extensions/readmoo/manifest.json24
-rw-r--r--browser/components/search/extensions/salidzinilv/favicon.icobin0 -> 3638 bytes
-rw-r--r--browser/components/search/extensions/salidzinilv/manifest.json26
-rw-r--r--browser/components/search/extensions/seznam-cz/favicon.icobin0 -> 1743 bytes
-rw-r--r--browser/components/search/extensions/seznam-cz/manifest.json26
-rw-r--r--browser/components/search/extensions/tyda-sv-SE/favicon.icobin0 -> 379 bytes
-rw-r--r--browser/components/search/extensions/tyda-sv-SE/manifest.json24
-rw-r--r--browser/components/search/extensions/vatera/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/vatera/manifest.json25
-rw-r--r--browser/components/search/extensions/webde/favicon.icobin0 -> 3638 bytes
-rw-r--r--browser/components/search/extensions/webde/manifest.json24
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/NN/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/NO/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/af/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/an/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ar/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ast/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/az/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/be-tarask/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/be/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/bg/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/bn/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/br/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/bs/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ca/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/cy/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/cz/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/da/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/de/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/dsb/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/el/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/en/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/eo/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/es/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/et/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/eu/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/fa/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/fi/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/fr/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/fy-NL/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ga-IE/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/gd/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/gl/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/gn/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/gu/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/he/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/hi/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/hr/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/hsb/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/hu/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/hy/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ia/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/id/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/is/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/it/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ja/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ka/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/kab/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/kk/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/km/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/kn/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/kr/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/lij/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/lo/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/lt/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ltg/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/lv/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/mk/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/mr/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ms/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/my/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ne/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/nl/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/oc/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/pa/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/pl/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/pt/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/rm/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ro/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ru/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/si/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/sk/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/sl/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/sq/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/sr/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/sv-SE/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ta/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/te/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/th/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/tl/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/tr/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/uk/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/ur/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/uz/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/vi/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/wo/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/zh-CN/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/_locales/zh-TW/messages.json20
-rw-r--r--browser/components/search/extensions/wikipedia/favicon.icobin0 -> 884 bytes
-rw-r--r--browser/components/search/extensions/wikipedia/manifest.json27
-rw-r--r--browser/components/search/extensions/wiktionary/_locales/oc/messages.json20
-rw-r--r--browser/components/search/extensions/wiktionary/_locales/te/messages.json20
-rw-r--r--browser/components/search/extensions/wiktionary/favicon.icobin0 -> 318 bytes
-rw-r--r--browser/components/search/extensions/wiktionary/manifest.json26
-rw-r--r--browser/components/search/extensions/wolnelektury-pl/favicon.pngbin0 -> 304 bytes
-rw-r--r--browser/components/search/extensions/wolnelektury-pl/manifest.json24
-rw-r--r--browser/components/search/extensions/yahoo-jp-auctions/favicon.icobin0 -> 2672 bytes
-rw-r--r--browser/components/search/extensions/yahoo-jp-auctions/manifest.json25
-rw-r--r--browser/components/search/extensions/yahoo-jp/favicon.icobin0 -> 5430 bytes
-rw-r--r--browser/components/search/extensions/yahoo-jp/manifest.json24
-rw-r--r--browser/components/search/extensions/yandex/_locales/az/messages.json38
-rw-r--r--browser/components/search/extensions/yandex/_locales/by/messages.json38
-rw-r--r--browser/components/search/extensions/yandex/_locales/en/messages.json38
-rw-r--r--browser/components/search/extensions/yandex/_locales/kk/messages.json38
-rw-r--r--browser/components/search/extensions/yandex/_locales/ru/messages.json38
-rw-r--r--browser/components/search/extensions/yandex/_locales/tr/messages.json38
-rw-r--r--browser/components/search/extensions/yandex/_locales/ua/messages.json23
-rw-r--r--browser/components/search/extensions/yandex/manifest.json59
-rw-r--r--browser/components/search/extensions/yandex/yandex-en.icobin0 -> 1338 bytes
-rw-r--r--browser/components/search/extensions/yandex/yandex-ru.icobin0 -> 1368 bytes
-rw-r--r--browser/components/search/jar.mn13
-rw-r--r--browser/components/search/metrics.yaml355
-rw-r--r--browser/components/search/moz.build29
-rw-r--r--browser/components/search/pings.yaml22
-rw-r--r--browser/components/search/schema/Readme.txt7
-rw-r--r--browser/components/search/schema/search-telemetry-schema.json417
-rw-r--r--browser/components/search/schema/search-telemetry-ui-schema.json23
-rw-r--r--browser/components/search/test/browser/426329.xml11
-rw-r--r--browser/components/search/test/browser/browser.toml103
-rw-r--r--browser/components/search/test/browser/browser_426329.js301
-rw-r--r--browser/components/search/test/browser/browser_addKeywordSearch.js115
-rw-r--r--browser/components/search/test/browser/browser_contentContextMenu.js230
-rw-r--r--browser/components/search/test/browser/browser_contentContextMenu.xhtml22
-rw-r--r--browser/components/search/test/browser/browser_contentSearch.js516
-rw-r--r--browser/components/search/test/browser/browser_contentSearchUI.js1158
-rw-r--r--browser/components/search/test/browser/browser_contentSearchUI_default.js210
-rw-r--r--browser/components/search/test/browser/browser_contextSearchTabPosition.js94
-rw-r--r--browser/components/search/test/browser/browser_contextmenu.js249
-rw-r--r--browser/components/search/test/browser/browser_contextmenu_whereToOpenLink.js183
-rw-r--r--browser/components/search/test/browser/browser_defaultPrivate_nimbus.js155
-rw-r--r--browser/components/search/test/browser/browser_google_behavior.js215
-rw-r--r--browser/components/search/test/browser/browser_hiddenOneOffs_diacritics.js75
-rw-r--r--browser/components/search/test/browser/browser_ime_composition.js77
-rw-r--r--browser/components/search/test/browser/browser_oneOffContextMenu.js89
-rw-r--r--browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js236
-rw-r--r--browser/components/search/test/browser/browser_private_search_perwindowpb.js84
-rw-r--r--browser/components/search/test/browser/browser_rich_suggestions.js125
-rw-r--r--browser/components/search/test/browser/browser_searchEngine_behaviors.js223
-rw-r--r--browser/components/search/test/browser/browser_search_annotation.js176
-rw-r--r--browser/components/search/test/browser/browser_search_discovery.js132
-rw-r--r--browser/components/search/test/browser/browser_search_nimbus_reload.js55
-rw-r--r--browser/components/search/test/browser/browser_searchbar_addEngine.js99
-rw-r--r--browser/components/search/test/browser/browser_searchbar_context.js246
-rw-r--r--browser/components/search/test/browser/browser_searchbar_default.js221
-rw-r--r--browser/components/search/test/browser/browser_searchbar_enter.js152
-rw-r--r--browser/components/search/test/browser/browser_searchbar_keyboard_navigation.js663
-rw-r--r--browser/components/search/test/browser/browser_searchbar_openpopup.js812
-rw-r--r--browser/components/search/test/browser/browser_searchbar_results.js60
-rw-r--r--browser/components/search/test/browser/browser_searchbar_smallpanel_keyboard_navigation.js453
-rw-r--r--browser/components/search/test/browser/browser_searchbar_widths.js33
-rw-r--r--browser/components/search/test/browser/browser_tooManyEnginesOffered.js68
-rw-r--r--browser/components/search/test/browser/browser_trending_suggestions.js240
-rw-r--r--browser/components/search/test/browser/contentSearchBadImage.xml6
-rw-r--r--browser/components/search/test/browser/contentSearchSuggestions.sjs9
-rw-r--r--browser/components/search/test/browser/contentSearchSuggestions.xml6
-rw-r--r--browser/components/search/test/browser/contentSearchUI.html22
-rw-r--r--browser/components/search/test/browser/contentSearchUI.js13
-rw-r--r--browser/components/search/test/browser/discovery.html9
-rw-r--r--browser/components/search/test/browser/google_codes/browser.toml4
-rw-r--r--browser/components/search/test/browser/head.js133
-rw-r--r--browser/components/search/test/browser/mozsearch.sjs11
-rw-r--r--browser/components/search/test/browser/opensearch.html10
-rw-r--r--browser/components/search/test/browser/search-engines/basic/manifest.json20
-rw-r--r--browser/components/search/test/browser/search-engines/private/manifest.json20
-rw-r--r--browser/components/search/test/browser/searchSuggestionEngine.sjs53
-rw-r--r--browser/components/search/test/browser/telemetry/browser.toml197
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_categorization_enabled_by_nimbus_variable.js186
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_glean_serp_event_telemetry_enabled_by_nimbus_variable.js167
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_abandonment.js294
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_aboutHome.js135
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_adImpression_component.js502
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_categorization_timing.js83
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_content.js204
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_ad_values.js190
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_download_timer.js313
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_extraction.js263
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_region.js120
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting.js225
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer.js287
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_domain_categorization_reporting_timer_wakeup.js202
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached.js201
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_cached_serp.js218
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_content.js633
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_multiple_tabs.js206
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_non_ad.js146
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_query_params.js387
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_redirect.js372
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_engagement_target.js457
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_new_window.js350
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_private.js135
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_remote_settings_sync.js329
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_searchbar.js442
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_shopping.js143
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources.js349
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_about.js225
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads.js378
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_clicks.js373
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_data_attributes.js173
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_ads_load_events.js142
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_in_content.js506
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_navigation.js684
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_sources_webextension.js219
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_in_content.js524
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_provider.js529
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_multi_tab.js875
-rw-r--r--browser/components/search/test/browser/telemetry/browser_search_telemetry_spa_single_tab.js661
-rw-r--r--browser/components/search/test/browser/telemetry/cacheable.html12
-rw-r--r--browser/components/search/test/browser/telemetry/cacheable.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/domain_category_mappings.json8
-rw-r--r--browser/components/search/test/browser/telemetry/head-spa.js259
-rw-r--r--browser/components/search/test/browser/telemetry/head.js621
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_ad.sjs10
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_final.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_once.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_thrice.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/redirect_twice.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetry.html11
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd.html13
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel.html116
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_below_the_fold.html83
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_doubled.html182
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_first_element_non_visible.html85
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_hidden.html87
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_carousel_outer_container.html83
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_query_parameters.html36
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_text.html112
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_components_visibility.html46
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes.html10
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_href.html10
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_dataAttributes_none.html10
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html12
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html17
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_nonAdsLink_redirect_nonTopLoaded.html^headers^4
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html38
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html39
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html12
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_searchbox_with_content_redirect.html^headers^1
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryAd_shopping.html15
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorization.html45
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationCapProcessedDomains.html64
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainCategorizationReporting.html45
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetryDomainExtraction.html84
-rw-r--r--browser/components/search/test/browser/telemetry/searchTelemetrySinglePageApp.html243
-rw-r--r--browser/components/search/test/browser/telemetry/serp.css164
-rw-r--r--browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.html14
-rw-r--r--browser/components/search/test/browser/telemetry/slow_loading_page_with_ads.sjs21
-rw-r--r--browser/components/search/test/browser/telemetry/slow_loading_page_with_ads_on_load_event.html30
-rw-r--r--browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.sjs9
-rw-r--r--browser/components/search/test/browser/telemetry/telemetrySearchSuggestions.xml6
-rw-r--r--browser/components/search/test/browser/test.html8
-rw-r--r--browser/components/search/test/browser/testEngine.xml12
-rw-r--r--browser/components/search/test/browser/testEngine_chromeicon.xml12
-rw-r--r--browser/components/search/test/browser/testEngine_diacritics.xml12
-rw-r--r--browser/components/search/test/browser/testEngine_dupe.xml12
-rw-r--r--browser/components/search/test/browser/testEngine_mozsearch.xml14
-rw-r--r--browser/components/search/test/browser/test_search.html1
-rw-r--r--browser/components/search/test/browser/tooManyEnginesOffered.html13
-rw-r--r--browser/components/search/test/browser/trendingSuggestionEngine.sjs54
-rw-r--r--browser/components/search/test/marionette/manifest.toml4
-rw-r--r--browser/components/search/test/marionette/test_engines_on_restart.py40
-rw-r--r--browser/components/search/test/unit/domain_category_mappings_1a.json3
-rw-r--r--browser/components/search/test/unit/domain_category_mappings_1b.json3
-rw-r--r--browser/components/search/test/unit/domain_category_mappings_2a.json3
-rw-r--r--browser/components/search/test/unit/domain_category_mappings_2b.json3
-rw-r--r--browser/components/search/test/unit/test_search_telemetry_categorization_logic.js346
-rw-r--r--browser/components/search/test/unit/test_search_telemetry_categorization_process_domains.js89
-rw-r--r--browser/components/search/test/unit/test_search_telemetry_categorization_sync.js423
-rw-r--r--browser/components/search/test/unit/test_search_telemetry_compare_urls.js188
-rw-r--r--browser/components/search/test/unit/test_search_telemetry_config_validation.js137
-rw-r--r--browser/components/search/test/unit/test_urlTelemetry.js306
-rw-r--r--browser/components/search/test/unit/test_urlTelemetry_generic.js329
-rw-r--r--browser/components/search/test/unit/xpcshell.toml29
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&regexp=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
new file mode 100644
index 0000000000..ac5a2bbb0a
--- /dev/null
+++ b/browser/components/search/extensions/1und1/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..42b4f90149
--- /dev/null
+++ b/browser/components/search/extensions/allegro-pl/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/browser/components/search/extensions/amazon/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/browser/components/search/extensions/amazondotcn/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..1c39eaf8fe
--- /dev/null
+++ b/browser/components/search/extensions/amazondotcom/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..ba687ca8e7
--- /dev/null
+++ b/browser/components/search/extensions/azerdict/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..e1c770cc4b
--- /dev/null
+++ b/browser/components/search/extensions/baidu/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..fdc021cfeb
--- /dev/null
+++ b/browser/components/search/extensions/bing/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..c2d46117ef
--- /dev/null
+++ b/browser/components/search/extensions/bok-NO/favicon.png
Binary files differ
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
new file mode 100644
index 0000000000..3c77b64d3c
--- /dev/null
+++ b/browser/components/search/extensions/ceneji/favicon.png
Binary files differ
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
new file mode 100644
index 0000000000..f128244fed
--- /dev/null
+++ b/browser/components/search/extensions/coccoc/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..ed803f50e2
--- /dev/null
+++ b/browser/components/search/extensions/daum-kr/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..3ad20825c1
--- /dev/null
+++ b/browser/components/search/extensions/ddg/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..3af7a36484
--- /dev/null
+++ b/browser/components/search/extensions/ebay/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..cc72d09d6d
--- /dev/null
+++ b/browser/components/search/extensions/ecosia/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..20750d0c19
--- /dev/null
+++ b/browser/components/search/extensions/eudict/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..990cf93298
--- /dev/null
+++ b/browser/components/search/extensions/faclair-beag/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..020006b5e4
--- /dev/null
+++ b/browser/components/search/extensions/gmx/favicon.png
Binary files differ
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
new file mode 100644
index 0000000000..82339b3b1d
--- /dev/null
+++ b/browser/components/search/extensions/google/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..e35572a557
--- /dev/null
+++ b/browser/components/search/extensions/gulesider-NO/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..04e5e344ef
--- /dev/null
+++ b/browser/components/search/extensions/leo_ende_de/favicon.png
Binary files differ
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
new file mode 100644
index 0000000000..aa42cda97f
--- /dev/null
+++ b/browser/components/search/extensions/longdo/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..9f1bed60f8
--- /dev/null
+++ b/browser/components/search/extensions/mailcom/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..a2d3a48883
--- /dev/null
+++ b/browser/components/search/extensions/mailru/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..051204c35c
--- /dev/null
+++ b/browser/components/search/extensions/mapy-cz/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..dc9ad5b2a9
--- /dev/null
+++ b/browser/components/search/extensions/mercadolibre/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..dc9ad5b2a9
--- /dev/null
+++ b/browser/components/search/extensions/mercadolivre/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..eed93a92cb
--- /dev/null
+++ b/browser/components/search/extensions/naver-kr/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..044d4f13d4
--- /dev/null
+++ b/browser/components/search/extensions/odpiralni/favicon.png
Binary files differ
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
new file mode 100644
index 0000000000..36f0cff233
--- /dev/null
+++ b/browser/components/search/extensions/pazaruvaj/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..98924439d5
--- /dev/null
+++ b/browser/components/search/extensions/priberam/favicon.png
Binary files differ
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
new file mode 100644
index 0000000000..feac665f71
--- /dev/null
+++ b/browser/components/search/extensions/prisjakt-sv-SE/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..d43d1d5aa6
--- /dev/null
+++ b/browser/components/search/extensions/qwant/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..d43d1d5aa6
--- /dev/null
+++ b/browser/components/search/extensions/qwantjr/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..66afe98469
--- /dev/null
+++ b/browser/components/search/extensions/rakuten/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..75396dc9ca
--- /dev/null
+++ b/browser/components/search/extensions/readmoo/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..0a7d01cae8
--- /dev/null
+++ b/browser/components/search/extensions/salidzinilv/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..f3e078a107
--- /dev/null
+++ b/browser/components/search/extensions/seznam-cz/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..7415cbb160
--- /dev/null
+++ b/browser/components/search/extensions/tyda-sv-SE/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..5b02f16cb9
--- /dev/null
+++ b/browser/components/search/extensions/vatera/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..f0ef93d209
--- /dev/null
+++ b/browser/components/search/extensions/webde/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..4314071e24
--- /dev/null
+++ b/browser/components/search/extensions/wikipedia/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..31b0e38092
--- /dev/null
+++ b/browser/components/search/extensions/wiktionary/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..77f6db5322
--- /dev/null
+++ b/browser/components/search/extensions/wolnelektury-pl/favicon.png
Binary files differ
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
new file mode 100644
index 0000000000..4401c7a40e
--- /dev/null
+++ b/browser/components/search/extensions/yahoo-jp-auctions/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..34a916ccde
--- /dev/null
+++ b/browser/components/search/extensions/yahoo-jp/favicon.ico
Binary files differ
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
new file mode 100644
index 0000000000..d1c3f3f8b1
--- /dev/null
+++ b/browser/components/search/extensions/yandex/yandex-en.ico
Binary files differ
diff --git a/browser/components/search/extensions/yandex/yandex-ru.ico b/browser/components/search/extensions/yandex/yandex-ru.ico
new file mode 100644
index 0000000000..eb187398c7
--- /dev/null
+++ b/browser/components/search/extensions/yandex/yandex-ru.ico
Binary files differ
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 &#9825;</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&amp;locale={moz:locale}&amp;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"]