summaryrefslogtreecommitdiffstats
path: root/browser/components/search/test/unit
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/search/test/unit')
-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
12 files changed, 1859 insertions, 0 deletions
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"]