diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/search/test/unit | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/search/test/unit')
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"] |