/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ // Tests the `urlbar-keyword-exposure` ping. const WAIT_FOR_PING_TIMEOUT_MS = 1000; // Avoid timeouts in verify mode, especially on Mac. requestLongerTimeout(3); add_setup(async function test_setup() { await PlacesUtils.history.clear(); Services.fog.testResetFOG(); // Add a mock engine so we don't hit the network. await SearchTestUtils.installSearchExtension({}, { setAsDefault: true }); registerCleanupFunction(() => { Services.fog.testResetFOG(); }); }); add_task(async function oneKeyword_noMatch_1() { await doTest({ keywords: ["example"], searchStrings: ["exam"], expectedEvents: [], }); }); add_task(async function oneKeyword_noMatch_2() { await doTest({ keywords: ["exam"], searchStrings: ["example"], expectedEvents: [], }); }); add_task(async function oneKeyword_oneMatch_terminal_1() { await doTest({ keywords: ["example"], searchStrings: ["example"], expectedEvents: [{ extra: { keyword: "example", terminal: true } }], }); }); add_task(async function oneKeyword_oneMatch_terminal_2() { await doTest({ keywords: ["example"], searchStrings: ["exam", "example"], expectedEvents: [{ extra: { keyword: "example", terminal: true } }], }); }); add_task(async function oneKeyword_oneMatch_nonterminal_1() { await doTest({ keywords: ["example"], searchStrings: ["example", "exam"], expectedEvents: [{ extra: { keyword: "example", terminal: false } }], }); }); add_task(async function oneKeyword_oneMatch_nonterminal_2() { await doTest({ keywords: ["example"], searchStrings: ["ex", "example", "exam"], expectedEvents: [{ extra: { keyword: "example", terminal: false } }], }); }); add_task(async function oneKeyword_dupeMatches_terminal_1() { await doTest({ keywords: ["example"], searchStrings: ["example", "example"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: true } }, ], }); }); add_task(async function oneKeyword_dupeMatches_terminal_2() { await doTest({ keywords: ["example"], searchStrings: ["example", "exampl", "example"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: true } }, ], }); }); add_task(async function oneKeyword_dupeMatches_terminal_3() { await doTest({ keywords: ["example"], searchStrings: ["exam", "example", "example"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: true } }, ], }); }); add_task(async function oneKeyword_dupeMatches_terminal_4() { await doTest({ keywords: ["example"], searchStrings: ["exam", "example", "exampl", "example"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: true } }, ], }); }); add_task(async function oneKeyword_dupeMatches_nonterminal_1() { await doTest({ keywords: ["example"], searchStrings: ["example", "example", "exampl"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: false } }, ], }); }); add_task(async function oneKeyword_dupeMatches_nonterminal_2() { await doTest({ keywords: ["example"], searchStrings: ["exam", "example", "example", "exampl"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: false } }, ], }); }); add_task(async function oneKeyword_dupeMatches_nonterminal_3() { await doTest({ keywords: ["example"], searchStrings: ["example", "exam", "example", "exampl"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: false } }, ], }); }); add_task(async function oneKeyword_dupeMatches_nonterminal_4() { await doTest({ keywords: ["example"], searchStrings: ["exam", "example", "exampl", "example", "exampl"], expectedEvents: [ { extra: { keyword: "example", terminal: false } }, { extra: { keyword: "example", terminal: false } }, ], }); }); add_task(async function manyKeywords_noMatch() { await doTest({ keywords: ["foo", "bar", "baz"], searchStrings: ["example"], expectedEvents: [], }); }); add_task(async function manyKeywords_oneMatch_terminal_1() { await doTest({ keywords: ["foo", "bar", "baz"], searchStrings: ["bar"], expectedEvents: [{ extra: { keyword: "bar", terminal: true } }], }); }); add_task(async function manyKeywords_oneMatch_terminal_2() { await doTest({ keywords: ["foo", "bar", "baz"], searchStrings: ["example", "bar"], expectedEvents: [{ extra: { keyword: "bar", terminal: true } }], }); }); add_task(async function manyKeywords_oneMatch_nonterminal_1() { await doTest({ keywords: ["foo", "bar", "baz"], searchStrings: ["bar", "example"], expectedEvents: [{ extra: { keyword: "bar", terminal: false } }], }); }); add_task(async function manyKeywords_oneMatch_nonterminal_2() { await doTest({ keywords: ["foo", "bar", "baz"], searchStrings: ["exam", "bar", "example"], expectedEvents: [{ extra: { keyword: "bar", terminal: false } }], }); }); add_task(async function manyKeywords_manyMatches_terminal_1() { let keywords = ["foo", "bar", "baz"]; await doTest({ keywords, searchStrings: keywords, expectedEvents: keywords.map((keyword, i) => ({ extra: { keyword, terminal: i == keywords.length - 1 }, })), }); }); add_task(async function manyKeywords_manyMatches_terminal_2() { let keywords = ["foo", "bar", "baz"]; await doTest({ keywords, searchStrings: ["exam", "foo", "exampl", "bar", "example", "baz"], expectedEvents: keywords.map((keyword, i) => ({ extra: { keyword, terminal: i == keywords.length - 1 }, })), }); }); add_task(async function manyKeywords_manyMatches_nonterminal_1() { let keywords = ["foo", "bar", "baz"]; await doTest({ keywords, searchStrings: ["foo", "bar", "baz", "example"], expectedEvents: keywords.map(keyword => ({ extra: { keyword, terminal: false }, })), }); }); add_task(async function manyKeywords_manyMatches_nonterminal_2() { let keywords = ["foo", "bar", "baz"]; await doTest({ keywords, searchStrings: ["exam", "foo", "exampl", "bar", "example", "baz", "exam"], expectedEvents: keywords.map(keyword => ({ extra: { keyword, terminal: false }, })), }); }); add_task(async function manyKeywords_dupeMatches_terminal() { let keywords = ["foo", "bar", "baz"]; let searchStrings = [...keywords, ...keywords]; await doTest({ keywords, searchStrings, expectedEvents: searchStrings.map((keyword, i) => ({ extra: { keyword, terminal: i == 2 * keywords.length - 1 }, })), }); }); add_task(async function manyKeywords_dupeMatches_nonterminal() { let keywords = ["foo", "bar", "baz"]; let searchStrings = [...keywords, ...keywords, "example"]; await doTest({ keywords, searchStrings, expectedEvents: [...keywords, ...keywords].map(keyword => ({ extra: { keyword, terminal: false }, })), }); }); add_task(async function manyResults() { await doTest({ keywords: [ // "foo" matches different results of the same type { keyword: "foo", resultType: "history" }, { keyword: "foo", resultType: "history" }, { keyword: "foo", resultType: "history" }, // "bar" matches different result types { keyword: "bar", resultType: "history" }, { keyword: "bar", resultType: "bookmark" }, { keyword: "baz", resultType: "bookmark" }, ], searchStrings: ["foo", "bar", "baz", "bar"], expectedEvents: [ { extra: { keyword: "foo", result: "history", terminal: false } }, { extra: { keyword: "foo", result: "history", terminal: false } }, { extra: { keyword: "foo", result: "history", terminal: false } }, { extra: { keyword: "bar", result: "history", terminal: false } }, { extra: { keyword: "bar", result: "bookmark", terminal: false } }, { extra: { keyword: "baz", result: "bookmark", terminal: false } }, { extra: { keyword: "bar", result: "history", terminal: true } }, { extra: { keyword: "bar", result: "bookmark", terminal: true } }, ], }); }); add_task(async function searchStringNormalization_terminal() { await doTest({ keywords: ["example"], searchStrings: [" ExaMPLe "], expectedEvents: [{ extra: { keyword: "example", terminal: true } }], }); }); add_task(async function searchStringNormalization_nonterminal() { await doTest({ keywords: ["example"], searchStrings: [" ExaMPLe ", "foo"], expectedEvents: [{ extra: { keyword: "example", terminal: false } }], }); }); add_task(async function multiWordKeyword() { await doTest({ keywords: ["this has multiple words"], searchStrings: ["this has multiple words"], expectedEvents: [ { extra: { keyword: "this has multiple words", terminal: true } }, ], }); }); add_task(async function subset_matched() { await doTest({ // "history" is in `exposureResults` but not `keywordExposureResults` exposureResults: "bookmark,history", keywordExposureResults: "bookmark", keywords: [{ keyword: "example", resultType: "bookmark" }], searchStrings: ["example"], expectedEvents: [ { extra: { keyword: "example", result: "bookmark", terminal: true } }, ], }); }); add_task(async function subset_notMatched() { await doTest({ // "history" is in `exposureResults` but not `keywordExposureResults` exposureResults: "bookmark,history", keywordExposureResults: "bookmark", keywords: [{ keyword: "example", resultType: "history" }], searchStrings: ["example"], expectedEvents: [], }); }); add_task(async function superset_matched() { await doTest({ // "history" is in `keywordExposureResults` but not `exposureResults` exposureResults: "bookmark", keywordExposureResults: "bookmark,history", keywords: [{ keyword: "example", resultType: "bookmark" }], searchStrings: ["example"], expectedEvents: [ { extra: { keyword: "example", result: "bookmark", terminal: true } }, ], }); }); add_task(async function superset_notMatched() { await doTest({ // "history" is in `keywordExposureResults` but not `exposureResults` exposureResults: "bookmark", keywordExposureResults: "bookmark,history", keywords: [{ keyword: "example", resultType: "history" }], searchStrings: ["example"], expectedEvents: [], }); }); // Smoke test that ends a session with an engagement instead of an abandonment // as other tasks in this file do. add_task(async function engagement() { await BrowserTestUtils.withNewTab("about:blank", async () => { await doTest({ keywords: ["example"], searchStrings: ["example"], endSession: () => // Hit the Enter key on the heuristic search result. UrlbarTestUtils.promisePopupClose(window, () => EventUtils.synthesizeKey("KEY_Enter") ), expectedEvents: [{ extra: { keyword: "example", terminal: true } }], }); }); }); // Smoke test that uses Nimbus instead of a pref as other tasks in this file do. add_task(async function nimbus() { let keywords = ["foo", "bar", "baz"]; await doTest({ useNimbus: true, keywords, searchStrings: keywords, expectedEvents: keywords.map((keyword, i) => ({ extra: { keyword, terminal: i == keywords.length - 1 }, })), }); }); // The ping should not be submitted for sessions in private windows. add_task(async function privateWindow() { let privateWin = await BrowserTestUtils.openNewBrowserWindow({ private: true, }); await doTest({ win: privateWin, keywords: ["example"], searchStrings: ["example"], expectedEvents: [], }); await BrowserTestUtils.closeWindow(privateWin); }); // Test a different sap. We choose handoff because it's easy to test. // More saps are tested in the engagement telemetry tests. add_task(async function sapUrlbarHandoff() { // Simulate handoff session. gURLBar._isHandoffSession = true; await doTest({ keywords: ["example"], searchStrings: ["example"], expectedEvents: [ { extra: { keyword: "example", terminal: true, sap: "handoff" } }, ], }); }); async function doTest({ keywords, searchStrings, expectedEvents, exposureResults = "history,bookmark", keywordExposureResults = exposureResults, useNimbus = false, win = window, endSession = () => UrlbarTestUtils.promisePopupClose(win, () => win.gURLBar.blur()), }) { // Assume all callers are testing with history and/or bookmarks. let resultSourceByType = { history: UrlbarUtils.RESULT_SOURCE.HISTORY, bookmark: UrlbarUtils.RESULT_SOURCE.BOOKMARKS, }; // Map the keywords array to objects: `{ keyword, resultType }` keywords = keywords.map(keyword => typeof keyword == "string" ? { keyword, resultType: "history" } : keyword ); // Register a high-priority provider that returns the given types of results // when the search string matches a keyword. let provider = new UrlbarTestUtils.TestProvider({ priority: Infinity, results: [], }); let getMatchingKeywords = context => keywords.filter( ({ keyword }) => keyword == context.trimmedLowerCaseSearchString ); provider.isActive = async context => { return !!getMatchingKeywords(context).length; }; provider.startQuery = (context, addCallback) => { let kws = getMatchingKeywords(context); for (let { resultType } of kws) { let source = resultSourceByType[resultType]; if (!source) { let msg = "No result source for type: " + resultType; Assert.ok(false, msg); throw new Error(msg); } addCallback( provider, new UrlbarResult({ type: UrlbarUtils.RESULT_TYPE.URL, source, payload: { url: "https://example.com/" }, }) ); } }; let providersManager = ProvidersManager.getInstanceForSap("urlbar"); providersManager.registerProvider(provider); registerCleanupFunction(() => providersManager.unregisterProvider(provider)); // Set up the prefs/Nimbus. let nimbusCleanup; if (useNimbus) { nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({ exposureResults, keywordExposureResults, }); } else { await SpecialPowers.pushPrefEnv({ set: [ ["browser.urlbar.exposureResults", exposureResults], ["browser.urlbar.keywordExposureResults", keywordExposureResults], ], }); } // Do the searches and end the session. let pingPromise = waitForPing(); for (let value of searchStrings) { await UrlbarTestUtils.promiseAutocompleteResultPopup({ value, window: win, }); } await endSession(); // Wait `WAIT_FOR_PING_TIMEOUT_MS` for the ping to be submitted before // reporting a timeout. Note that some tasks do not expect a ping to be // submitted, and they rely on this timeout behavior. info("Awaiting ping promise"); let events = null; events = await Promise.race([ pingPromise, new Promise(resolve => // eslint-disable-next-line mozilla/no-arbitrary-setTimeout setTimeout(() => { if (!events) { info("Timed out waiting for ping"); } resolve([]); }, WAIT_FOR_PING_TIMEOUT_MS) ), ]); assertEvents(events, expectedEvents); if (nimbusCleanup) { await nimbusCleanup(); } else { await SpecialPowers.popPrefEnv(); } Services.fog.testResetFOG(); providersManager.unregisterProvider(provider); Assert.deepEqual( [...UrlbarPrefs.get("exposureResults").values()], [], "Sanity check: exposureResults is empty after clearing prefs/uninstalling experiment" ); Assert.strictEqual( UrlbarPrefs.get("keywordExposureResults").size, 0, "Sanity check: keywordExposureResults is empty after clearing prefs/uninstalling experiment" ); } function waitForPing() { return new Promise(resolve => { GleanPings.urlbarKeywordExposure.testBeforeNextSubmit(() => { let events = Glean.urlbar.keywordExposure.testGetValue(); info("testBeforeNextSubmit got events: " + JSON.stringify(events)); resolve(events); }); }); } function assertEvents(actual, expected) { info("Comparing events: " + JSON.stringify({ actual, expected })); // Add some expected boilerplate properties to the expected events so that // callers don't have to but so that we still check them. expected = expected.map(e => { // `testGetValue()` stringifies booleans for some reason. Let callers // specify booleans since booleans are correct, and stringify them here. e = stringifyBooleans(e); // Most tasks only use history results, so for convenience set the result // type here unless a task already did. e.extra.result ??= "history"; // Again, for convenience, use urlbar_newtab as the default sap. e.extra.sap ??= "urlbar_newtab"; return { category: "urlbar", name: "keyword_exposure", ...e, }; }); // Filter out properties from the actual events that aren't defined in the // expected events. Ignore unimportant properties like timestamps. actual = actual.map((a, i) => Object.fromEntries( Object.entries(a).filter(([key]) => expected[i]?.hasOwnProperty(key)) ) ); Assert.deepEqual(actual, expected, "Checking expected Glean events"); } function stringifyBooleans(obj) { let newObj = {}; for (let [key, value] of Object.entries(obj)) { if (value && typeof value == "object") { newObj[key] = stringifyBooleans(value); } else if (typeof value == "boolean") { newObj[key] = String(value); } else { newObj[key] = value; } } return newObj; }