From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../urlbar/tests/quicksuggest/unit/head.js | 911 +++++ .../tests/quicksuggest/unit/test_merinoClient.js | 647 ++++ .../unit/test_merinoClient_sessions.js | 402 ++ .../tests/quicksuggest/unit/test_quicksuggest.js | 1661 +++++++++ .../quicksuggest/unit/test_quicksuggest_addons.js | 558 +++ .../unit/test_quicksuggest_dynamicWikipedia.js | 103 + .../unit/test_quicksuggest_impressionCaps.js | 3907 ++++++++++++++++++++ .../quicksuggest/unit/test_quicksuggest_mdn.js | 190 + .../quicksuggest/unit/test_quicksuggest_merino.js | 574 +++ .../unit/test_quicksuggest_merinoSessions.js | 173 + .../unit/test_quicksuggest_migrate_v1.js | 490 +++ .../unit/test_quicksuggest_migrate_v2.js | 1355 +++++++ .../unit/test_quicksuggest_nonUniqueKeywords.js | 285 ++ .../unit/test_quicksuggest_offlineDefault.js | 127 + .../quicksuggest/unit/test_quicksuggest_pocket.js | 531 +++ .../test_quicksuggest_positionInSuggestions.js | 487 +++ .../unit/test_quicksuggest_scoreMap.js | 670 ++++ .../unit/test_quicksuggest_topPicks.js | 192 + .../quicksuggest/unit/test_quicksuggest_yelp.js | 842 +++++ .../tests/quicksuggest/unit/test_rust_ingest.js | 244 ++ .../tests/quicksuggest/unit/test_suggestionsMap.js | 293 ++ .../urlbar/tests/quicksuggest/unit/test_weather.js | 1402 +++++++ .../quicksuggest/unit/test_weather_keywords.js | 1503 ++++++++ .../urlbar/tests/quicksuggest/unit/xpcshell.toml | 51 + 24 files changed, 17598 insertions(+) create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/head.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_weather.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js create mode 100644 browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml (limited to 'browser/components/urlbar/tests/quicksuggest/unit') diff --git a/browser/components/urlbar/tests/quicksuggest/unit/head.js b/browser/components/urlbar/tests/quicksuggest/unit/head.js new file mode 100644 index 0000000000..c468e4526f --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/head.js @@ -0,0 +1,911 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../unit/head.js */ +/* eslint-disable jsdoc/require-param */ + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", +}); + +add_setup(async function setUpQuickSuggestXpcshellTest() { + // Initializing TelemetryEnvironment in an xpcshell environment requires + // jumping through a bunch of hoops. Suggest's use of TelemetryEnvironment is + // tested in browser tests, and there's no other necessary reason to wait for + // TelemetryEnvironment initialization in xpcshell tests, so just skip it. + UrlbarPrefs._testSkipTelemetryEnvironmentInit = true; +}); + +/** + * Adds two tasks: One with the Rust backend disabled and one with it enabled. + * The names of the task functions will be the name of the passed-in task + * function appended with "_rustDisabled" and "_rustEnabled". If the passed-in + * task doesn't have a name, "anonymousTask" will be used. Call this with the + * usual `add_task()` arguments. + */ +function add_tasks_with_rust(...args) { + let taskFnIndex = args.findIndex(a => typeof a == "function"); + let taskFn = args[taskFnIndex]; + + for (let rustEnabled of [false, true]) { + let newTaskFn = async (...taskFnArgs) => { + info("add_tasks_with_rust: Setting rustEnabled: " + rustEnabled); + UrlbarPrefs.set("quicksuggest.rustEnabled", rustEnabled); + info("add_tasks_with_rust: Done setting rustEnabled: " + rustEnabled); + + // The current backend may now start syncing, so wait for it to finish. + info("add_tasks_with_rust: Forcing sync"); + await QuickSuggestTestUtils.forceSync(); + info("add_tasks_with_rust: Done forcing sync"); + + let rv; + try { + info( + "add_tasks_with_rust: Calling original task function: " + taskFn.name + ); + rv = await taskFn(...taskFnArgs); + } catch (e) { + // Clearly report any unusual errors to make them easier to spot and to + // make the flow of the test clearer. The harness throws NS_ERROR_ABORT + // when a normal assertion fails, so don't report that. + if (e.result != Cr.NS_ERROR_ABORT) { + Assert.ok( + false, + "add_tasks_with_rust: The original task function threw an error: " + + e + ); + } + throw e; + } finally { + info( + "add_tasks_with_rust: Done calling original task function: " + + taskFn.name + ); + info("add_tasks_with_rust: Clearing rustEnabled"); + UrlbarPrefs.clear("quicksuggest.rustEnabled"); + info("add_tasks_with_rust: Done clearing rustEnabled"); + + // The current backend may now start syncing, so wait for it to finish. + info("add_tasks_with_rust: Forcing sync"); + await QuickSuggestTestUtils.forceSync(); + info("add_tasks_with_rust: Done forcing sync"); + } + return rv; + }; + + Object.defineProperty(newTaskFn, "name", { + value: + (taskFn.name || "anonymousTask") + + (rustEnabled ? "_rustEnabled" : "_rustDisabled"), + }); + let addTaskArgs = [...args]; + addTaskArgs[taskFnIndex] = newTaskFn; + add_task(...addTaskArgs); + } +} + +/** + * Returns an expected Wikipedia (non-sponsored) result that can be passed to + * `check_results()` regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeWikipediaResult({ + source, + provider, + keyword = "wikipedia", + title = "Wikipedia Suggestion", + url = "http://example.com/wikipedia", + originalUrl = "http://example.com/wikipedia", + icon = null, + iconBlob = new Blob([new Uint8Array([])]), + impressionUrl = "http://example.com/wikipedia-impression", + clickUrl = "http://example.com/wikipedia-click", + blockId = 1, + advertiser = "Wikipedia", + iabCategory = "5 - Education", + suggestedIndex = -1, + isSuggestedIndexRelativeToGroup = true, +}) { + let result = { + suggestedIndex, + isSuggestedIndexRelativeToGroup, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + title, + url, + originalUrl, + displayUrl: url.replace(/^https:\/\//, ""), + isSponsored: false, + qsSuggestion: keyword, + sponsoredAdvertiser: "Wikipedia", + sponsoredIabCategory: "5 - Education", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + telemetryType: "adm_nonsponsored", + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Wikipedia"; + result.payload.iconBlob = iconBlob; + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AdmWikipedia"; + result.payload.icon = icon; + result.payload.sponsoredImpressionUrl = impressionUrl; + result.payload.sponsoredClickUrl = clickUrl; + result.payload.sponsoredBlockId = blockId; + result.payload.sponsoredAdvertiser = advertiser; + result.payload.sponsoredIabCategory = iabCategory; + } + + return result; +} + +/** + * Returns an expected AMP (sponsored) result that can be passed to + * `check_results()` regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeAmpResult({ + source, + provider, + keyword = "amp", + title = "Amp Suggestion", + url = "http://example.com/amp", + originalUrl = "http://example.com/amp", + icon = null, + iconBlob = new Blob([new Uint8Array([])]), + impressionUrl = "http://example.com/amp-impression", + clickUrl = "http://example.com/amp-click", + blockId = 1, + advertiser = "Amp", + iabCategory = "22 - Shopping", + suggestedIndex = -1, + isSuggestedIndexRelativeToGroup = true, + requestId = undefined, +} = {}) { + let result = { + suggestedIndex, + isSuggestedIndexRelativeToGroup, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + title, + url, + originalUrl, + requestId, + displayUrl: url.replace(/^https:\/\//, ""), + isSponsored: true, + qsSuggestion: keyword, + sponsoredImpressionUrl: impressionUrl, + sponsoredClickUrl: clickUrl, + sponsoredBlockId: blockId, + sponsoredAdvertiser: advertiser, + sponsoredIabCategory: iabCategory, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + telemetryType: "adm_sponsored", + descriptionL10n: { id: "urlbar-result-action-sponsored" }, + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Amp"; + if (result.payload.source == "rust") { + result.payload.iconBlob = iconBlob; + } else { + result.payload.icon = icon; + } + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AdmWikipedia"; + result.payload.icon = icon; + } + + return result; +} + +/** + * Returns an expected MDN result that can be passed to `check_results()` + * regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeMdnResult({ url, title, description }) { + let finalUrl = new URL(url); + finalUrl.searchParams.set("utm_medium", "firefox-desktop"); + finalUrl.searchParams.set("utm_source", "firefox-suggest"); + finalUrl.searchParams.set( + "utm_campaign", + "firefox-mdn-web-docs-suggestion-experiment" + ); + finalUrl.searchParams.set("utm_content", "treatment"); + + let result = { + isBestMatch: true, + suggestedIndex: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + heuristic: false, + payload: { + telemetryType: "mdn", + title, + url: finalUrl.href, + originalUrl: url, + displayUrl: finalUrl.href.replace(/^https:\/\//, ""), + description, + icon: "chrome://global/skin/icons/mdn.svg", + shouldShowUrl: true, + bottomTextL10n: { id: "firefox-suggest-mdn-bottom-text" }, + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = "rust"; + result.payload.provider = "Mdn"; + } else { + result.payload.source = "remote-settings"; + result.payload.provider = "MDNSuggestions"; + } + + return result; +} + +/** + * Returns an expected AMO (addons) result that can be passed to + * `check_results()` regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeAmoResult({ + source, + provider, + title = "Amo Suggestion", + description = "Amo description", + url = "http://example.com/amo", + originalUrl = "http://example.com/amo", + icon = null, + setUtmParams = true, +}) { + if (setUtmParams) { + url = new URL(url); + url.searchParams.set("utm_medium", "firefox-desktop"); + url.searchParams.set("utm_source", "firefox-suggest"); + url = url.href; + } + + let result = { + isBestMatch: true, + suggestedIndex: 1, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + source, + provider, + title, + description, + url, + originalUrl, + icon, + displayUrl: url.replace(/^https:\/\//, ""), + shouldShowUrl: true, + bottomTextL10n: { id: "firefox-suggest-addons-recommended" }, + helpUrl: QuickSuggest.HELP_URL, + telemetryType: "amo", + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Amo"; + } else { + result.payload.source = source || "remote-settings"; + result.payload.provider = provider || "AddonSuggestions"; + } + + return result; +} + +/** + * Returns an expected weather result that can be passed to `check_results()` + * regardless of whether the Rust backend is enabled. + * + * @returns {object} + * An object that can be passed to `check_results()`. + */ +function makeWeatherResult({ + source, + provider, + telemetryType = undefined, + temperatureUnit = undefined, +} = {}) { + if (!temperatureUnit) { + temperatureUnit = + Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + } + + let result = { + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + suggestedIndex: 1, + payload: { + temperatureUnit, + url: MerinoTestUtils.WEATHER_SUGGESTION.url, + iconId: "6", + helpUrl: QuickSuggest.HELP_URL, + requestId: MerinoTestUtils.server.response.body.request_id, + source: "merino", + provider: "accuweather", + dynamicType: "weather", + city: MerinoTestUtils.WEATHER_SUGGESTION.city_name, + temperature: + MerinoTestUtils.WEATHER_SUGGESTION.current_conditions.temperature[ + temperatureUnit + ], + currentConditions: + MerinoTestUtils.WEATHER_SUGGESTION.current_conditions.summary, + forecast: MerinoTestUtils.WEATHER_SUGGESTION.forecast.summary, + high: MerinoTestUtils.WEATHER_SUGGESTION.forecast.high[temperatureUnit], + low: MerinoTestUtils.WEATHER_SUGGESTION.forecast.low[temperatureUnit], + shouldNavigate: true, + }, + }; + + if (UrlbarPrefs.get("quickSuggestRustEnabled")) { + result.payload.source = source || "rust"; + result.payload.provider = provider || "Weather"; + if (telemetryType !== null) { + result.payload.telemetryType = telemetryType || "weather"; + } + } else { + result.payload.source = source || "merino"; + result.payload.provider = provider || "accuweather"; + } + + return result; +} + +/** + * Tests quick suggest prefs migrations. + * + * @param {object} options + * The options object. + * @param {object} options.testOverrides + * An object that modifies how migration is performed. It has the following + * properties, and all are optional: + * + * {number} migrationVersion + * Migration will stop at this version, so for example you can test + * migration only up to version 1 even when the current actual version is + * larger than 1. + * {object} defaultPrefs + * An object that maps pref names (relative to `browser.urlbar`) to + * default-branch values. These should be the default prefs for the given + * `migrationVersion` and will be set as defaults before migration occurs. + * + * @param {string} options.scenario + * The scenario to set at the time migration occurs. + * @param {object} options.expectedPrefs + * The expected prefs after migration: `{ defaultBranch, userBranch }` + * Pref names should be relative to `browser.urlbar`. + * @param {object} [options.initialUserBranch] + * Prefs to set on the user branch before migration ocurs. Use these to + * simulate user actions like disabling prefs or opting in or out of the + * online modal. Pref names should be relative to `browser.urlbar`. + */ +async function doMigrateTest({ + testOverrides, + scenario, + expectedPrefs, + initialUserBranch = {}, +}) { + info( + "Testing migration: " + + JSON.stringify({ + testOverrides, + initialUserBranch, + scenario, + expectedPrefs, + }) + ); + + function setPref(branch, name, value) { + switch (typeof value) { + case "boolean": + branch.setBoolPref(name, value); + break; + case "number": + branch.setIntPref(name, value); + break; + case "string": + branch.setCharPref(name, value); + break; + default: + Assert.ok( + false, + `Pref type not handled for setPref: ${name} = ${value}` + ); + break; + } + } + + function getPref(branch, name) { + let type = typeof UrlbarPrefs.get(name); + switch (type) { + case "boolean": + return branch.getBoolPref(name); + case "number": + return branch.getIntPref(name); + case "string": + return branch.getCharPref(name); + default: + Assert.ok(false, `Pref type not handled for getPref: ${name} ${type}`); + break; + } + return null; + } + + let defaultBranch = Services.prefs.getDefaultBranch("browser.urlbar."); + let userBranch = Services.prefs.getBranch("browser.urlbar."); + + // Set initial prefs. `initialDefaultBranch` are firefox.js values, i.e., + // defaults immediately after startup and before any scenario update and + // migration happens. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + UrlbarPrefs.clear("quicksuggest.migrationVersion"); + let initialDefaultBranch = { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }; + for (let name of Object.keys(initialDefaultBranch)) { + userBranch.clearUserPref(name); + } + for (let [branch, prefs] of [ + [defaultBranch, initialDefaultBranch], + [userBranch, initialUserBranch], + ]) { + for (let [name, value] of Object.entries(prefs)) { + if (value !== undefined) { + setPref(branch, name, value); + } + } + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; + + // Update the scenario and check prefs twice. The first time the migration + // should happen, and the second time the migration should not happen and + // all the prefs should stay the same. + for (let i = 0; i < 2; i++) { + info(`Calling updateFirefoxSuggestScenario, i=${i}`); + + // Do the scenario update and set `isStartup` to simulate startup. + await UrlbarPrefs.updateFirefoxSuggestScenario({ + ...testOverrides, + scenario, + isStartup: true, + }); + + // Check expected pref values. Store expected effective values as we go so + // we can check them afterward. For a given pref, the expected effective + // value is the user value, or if there's not a user value, the default + // value. + let expectedEffectivePrefs = {}; + let { + defaultBranch: expectedDefaultBranch, + userBranch: expectedUserBranch, + } = expectedPrefs; + expectedDefaultBranch = expectedDefaultBranch || {}; + expectedUserBranch = expectedUserBranch || {}; + for (let [branch, prefs, branchType] of [ + [defaultBranch, expectedDefaultBranch, "default"], + [userBranch, expectedUserBranch, "user"], + ]) { + let entries = Object.entries(prefs); + if (!entries.length) { + continue; + } + + info( + `Checking expected prefs on ${branchType} branch after updating scenario` + ); + for (let [name, value] of entries) { + expectedEffectivePrefs[name] = value; + if (branch == userBranch) { + Assert.ok( + userBranch.prefHasUserValue(name), + `Pref ${name} is on user branch` + ); + } + Assert.equal( + getPref(branch, name), + value, + `Pref ${name} value on ${branchType} branch` + ); + } + } + + info( + `Making sure prefs on the default branch without expected user-branch values are not on the user branch` + ); + for (let name of Object.keys(initialDefaultBranch)) { + if (!expectedUserBranch.hasOwnProperty(name)) { + Assert.ok( + !userBranch.prefHasUserValue(name), + `Pref ${name} is not on user branch` + ); + } + } + + info(`Checking expected effective prefs`); + for (let [name, value] of Object.entries(expectedEffectivePrefs)) { + Assert.equal( + UrlbarPrefs.get(name), + value, + `Pref ${name} effective value` + ); + } + + let currentVersion = + testOverrides?.migrationVersion === undefined + ? UrlbarPrefs.FIREFOX_SUGGEST_MIGRATION_VERSION + : testOverrides.migrationVersion; + Assert.equal( + UrlbarPrefs.get("quicksuggest.migrationVersion"), + currentVersion, + "quicksuggest.migrationVersion is correct after migration" + ); + } + + // Clean up. + UrlbarPrefs._updatingFirefoxSuggestScenario = true; + UrlbarPrefs.clear("quicksuggest.migrationVersion"); + let userBranchNames = [ + ...Object.keys(initialUserBranch), + ...Object.keys(expectedPrefs.userBranch || {}), + ]; + for (let name of userBranchNames) { + userBranch.clearUserPref(name); + } + UrlbarPrefs._updatingFirefoxSuggestScenario = false; +} + +/** + * Does some "show less frequently" tests where the cap is set in remote + * settings and Nimbus. See `doOneShowLessFrequentlyTest()`. This function + * assumes the matching behavior implemented by the given `BaseFeature` is based + * on matching prefixes of the given keyword starting at the first word. It + * also assumes the `BaseFeature` provides suggestions in remote settings. + * + * @param {object} options + * Options object. + * @param {BaseFeature} options.feature + * The feature that provides the suggestion matched by the searches. + * @param {*} options.expectedResult + * The expected result that should be matched, for searches that are expected + * to match a result. Can also be a function; it's passed the current search + * string and it should return the expected result. + * @param {string} options.showLessFrequentlyCountPref + * The name of the pref that stores the "show less frequently" count being + * tested. + * @param {string} options.nimbusCapVariable + * The name of the Nimbus variable that stores the "show less frequently" cap + * being tested. + * @param {object} options.keyword + * The primary keyword to use during the test. + * @param {number} options.keywordBaseIndex + * The index in `keyword` to base substring checks around. This function will + * test substrings starting at the beginning of keyword and ending at the + * following indexes: one index before `keywordBaseIndex`, + * `keywordBaseIndex`, `keywordBaseIndex` + 1, `keywordBaseIndex` + 2, and + * `keywordBaseIndex` + 3. + */ +async function doShowLessFrequentlyTests({ + feature, + expectedResult, + showLessFrequentlyCountPref, + nimbusCapVariable, + keyword, + keywordBaseIndex = keyword.indexOf(" "), +}) { + // Do some sanity checks on the keyword. Any checks that fail are errors in + // the test. + if (keywordBaseIndex <= 0) { + throw new Error( + "keywordBaseIndex must be > 0, but it's " + keywordBaseIndex + ); + } + if (keyword.length < keywordBaseIndex + 3) { + throw new Error( + "keyword must have at least two chars after keywordBaseIndex" + ); + } + + let tests = [ + { + showLessFrequentlyCount: 0, + canShowLessFrequently: true, + newSearches: { + [keyword.substring(0, keywordBaseIndex - 1)]: false, + [keyword.substring(0, keywordBaseIndex)]: true, + [keyword.substring(0, keywordBaseIndex + 1)]: true, + [keyword.substring(0, keywordBaseIndex + 2)]: true, + [keyword.substring(0, keywordBaseIndex + 3)]: true, + }, + }, + { + showLessFrequentlyCount: 1, + canShowLessFrequently: true, + newSearches: { + [keyword.substring(0, keywordBaseIndex)]: false, + }, + }, + { + showLessFrequentlyCount: 2, + canShowLessFrequently: true, + newSearches: { + [keyword.substring(0, keywordBaseIndex + 1)]: false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + newSearches: { + [keyword.substring(0, keywordBaseIndex + 2)]: false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + newSearches: {}, + }, + ]; + + info("Testing 'show less frequently' with cap in remote settings"); + await doOneShowLessFrequentlyTest({ + tests, + feature, + expectedResult, + showLessFrequentlyCountPref, + rs: { + show_less_frequently_cap: 3, + }, + }); + + // Nimbus should override remote settings. + info("Testing 'show less frequently' with cap in Nimbus and remote settings"); + await doOneShowLessFrequentlyTest({ + tests, + feature, + expectedResult, + showLessFrequentlyCountPref, + rs: { + show_less_frequently_cap: 10, + }, + nimbus: { + [nimbusCapVariable]: 3, + }, + }); +} + +/** + * Does a group of searches, increments the "show less frequently" count, and + * repeats until all groups are done. The cap can be set by remote settings + * config and/or Nimbus. + * + * @param {object} options + * Options object. + * @param {BaseFeature} options.feature + * The feature that provides the suggestion matched by the searches. + * @param {*} options.expectedResult + * The expected result that should be matched, for searches that are expected + * to match a result. Can also be a function; it's passed the current search + * string and it should return the expected result. + * @param {string} options.showLessFrequentlyCountPref + * The name of the pref that stores the "show less frequently" count being + * tested. + * @param {object} options.tests + * An array where each item describes a group of new searches to perform and + * expected state. Each item should look like this: + * `{ showLessFrequentlyCount, canShowLessFrequently, newSearches }` + * + * {number} showLessFrequentlyCount + * The expected value of `showLessFrequentlyCount` before the group of + * searches is performed. + * {boolean} canShowLessFrequently + * The expected value of `canShowLessFrequently` before the group of + * searches is performed. + * {object} newSearches + * An object that maps each search string to a boolean that indicates + * whether the first remote settings suggestion should be triggered by the + * search string. Searches are cumulative: The intended use is to pass a + * large initial group of searches in the first search group, and then each + * following `newSearches` is a diff against the previous. + * @param {object} options.rs + * The remote settings config to set. + * @param {object} options.nimbus + * The Nimbus variables to set. + */ +async function doOneShowLessFrequentlyTest({ + feature, + expectedResult, + showLessFrequentlyCountPref, + tests, + rs = {}, + nimbus = {}, +}) { + // Disable Merino so we trigger only remote settings suggestions. The + // `BaseFeature` is expected to add remote settings suggestions using keywords + // start starting with the first word in each full keyword, but the mock + // Merino server will always return whatever suggestion it's told to return + // regardless of the search string. That means Merino will return a suggestion + // for a keyword that's smaller than the first full word. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + // Set Nimbus variables and RS config. + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(nimbus); + await QuickSuggestTestUtils.withConfig({ + config: rs, + callback: async () => { + let cumulativeSearches = {}; + + for (let { + showLessFrequentlyCount, + canShowLessFrequently, + newSearches, + } of tests) { + info( + "Starting subtest: " + + JSON.stringify({ + showLessFrequentlyCount, + canShowLessFrequently, + newSearches, + }) + ); + + Assert.equal( + feature.showLessFrequentlyCount, + showLessFrequentlyCount, + "showLessFrequentlyCount should be correct initially" + ); + Assert.equal( + UrlbarPrefs.get(showLessFrequentlyCountPref), + showLessFrequentlyCount, + "Pref should be correct initially" + ); + Assert.equal( + feature.canShowLessFrequently, + canShowLessFrequently, + "canShowLessFrequently should be correct initially" + ); + + // Merge the current `newSearches` object into the cumulative object. + cumulativeSearches = { + ...cumulativeSearches, + ...newSearches, + }; + + for (let [searchString, isExpected] of Object.entries( + cumulativeSearches + )) { + info("Doing search: " + JSON.stringify({ searchString, isExpected })); + + let results = []; + if (isExpected) { + results.push( + typeof expectedResult == "function" + ? expectedResult(searchString) + : expectedResult + ); + } + + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: results, + }); + } + + feature.incrementShowLessFrequentlyCount(); + } + }, + }); + + await cleanUpNimbus(); + UrlbarPrefs.clear(showLessFrequentlyCountPref); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); +} + +/** + * Queries the Rust component directly and checks the returned suggestions. The + * point is to make sure the Rust backend passes the correct providers to the + * Rust component depending on the types of enabled suggestions. Assuming the + * Rust component isn't buggy, it should return suggestions only for the + * passed-in providers. + * + * @param {object} options + * Options object + * @param {string} options.searchString + * The search string. + * @param {Array} options.tests + * Array of test objects: `{ prefs, expectedUrls }` + * + * For each object, the given prefs are set, the Rust component is queried + * using the given search string, and the URLs of the returned suggestions are + * compared to the given expected URLs (order doesn't matter). + * + * {object} prefs + * An object mapping pref names (relative to `browser.urlbar`) to values. + * These prefs will be set before querying and should be used to enable or + * disable particular types of suggestions. + * {Array} expectedUrls + * An array of the URLs of the suggestions that are expected to be returned. + * The order doesn't matter. + */ +async function doRustProvidersTests({ searchString, tests }) { + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + + for (let { prefs, expectedUrls } of tests) { + info( + "Starting Rust providers test: " + JSON.stringify({ prefs, expectedUrls }) + ); + + info("Setting prefs and forcing sync"); + for (let [name, value] of Object.entries(prefs)) { + UrlbarPrefs.set(name, value); + } + await QuickSuggestTestUtils.forceSync(); + + info("Querying with search string: " + JSON.stringify(searchString)); + let suggestions = await QuickSuggest.rustBackend.query(searchString); + info("Got suggestions: " + JSON.stringify(suggestions)); + + Assert.deepEqual( + suggestions.map(s => s.url).sort(), + expectedUrls.sort(), + "query() should return the expected suggestions (by URL)" + ); + + info("Clearing prefs and forcing sync"); + for (let name of Object.keys(prefs)) { + UrlbarPrefs.clear(name); + } + await QuickSuggestTestUtils.forceSync(); + } + + info("Clearing rustEnabled pref and forcing sync"); + UrlbarPrefs.clear("quicksuggest.rustEnabled"); + await QuickSuggestTestUtils.forceSync(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js new file mode 100644 index 0000000000..cd45cb11a7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js @@ -0,0 +1,647 @@ +/* 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/. */ + +// Test for MerinoClient. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + MerinoClient: "resource:///modules/MerinoClient.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +// Set the `merino.timeoutMs` pref to a large value so that the client will not +// inadvertently time out during fetches. This is especially important on CI and +// when running this test in verify mode. Tasks that specifically test timeouts +// may need to set a more reasonable value for their duration. +const TEST_TIMEOUT_MS = 30000; + +// The expected suggestion objects returned from `MerinoClient.fetch()`. +const EXPECTED_MERINO_SUGGESTIONS = []; + +const { SEARCH_PARAMS } = MerinoClient; + +let gClient; + +add_setup(async function init() { + UrlbarPrefs.set("merino.timeoutMs", TEST_TIMEOUT_MS); + registerCleanupFunction(() => { + UrlbarPrefs.clear("merino.timeoutMs"); + }); + + gClient = new MerinoClient(); + await MerinoTestUtils.server.start(); + + for (let suggestion of MerinoTestUtils.server.response.body.suggestions) { + EXPECTED_MERINO_SUGGESTIONS.push({ + ...suggestion, + request_id: MerinoTestUtils.server.response.body.request_id, + source: "merino", + }); + } +}); + +// Checks client names. +add_task(async function name() { + Assert.equal( + gClient.name, + "anonymous", + "gClient name is 'anonymous' since it wasn't given a name" + ); + + let client = new MerinoClient("New client"); + Assert.equal(client.name, "New client", "newClient name is correct"); +}); + +// Does a successful fetch. +add_task(async function success() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request successfully finished" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); +}); + +// Does a successful fetch that doesn't return any suggestions. +add_task(async function noSuggestions() { + let { suggestions } = MerinoTestUtils.server.response.body; + MerinoTestUtils.server.response.body.suggestions = []; + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + await fetchAndCheckSuggestions({ + expected: [], + }); + + Assert.equal( + gClient.lastFetchStatus, + "no_suggestion", + "The request successfully finished without suggestions" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.response.body.suggestions = suggestions; +}); + +// Checks a response that's valid but also has some unexpected properties. +add_task(async function unexpectedResponseProperties() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.unexpectedString = "some value"; + MerinoTestUtils.server.response.body.unexpectedArray = ["a", "b", "c"]; + MerinoTestUtils.server.response.body.unexpectedObject = { foo: "bar" }; + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request successfully finished" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); +}); + +// Checks some responses with unexpected response bodies. +add_task(async function unexpectedResponseBody() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let responses = [ + { body: {} }, + { body: { bogus: [] } }, + { body: { suggestions: {} } }, + { body: { suggestions: [] } }, + { body: "" }, + { body: "bogus", contentType: "text/html" }, + ]; + + for (let r of responses) { + info("Testing response: " + JSON.stringify(r)); + + MerinoTestUtils.server.response = r; + await fetchAndCheckSuggestions({ expected: [] }); + + Assert.equal( + gClient.lastFetchStatus, + "no_suggestion", + "The request successfully finished without suggestions" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: gClient, + }); + } + + MerinoTestUtils.server.reset(); +}); + +// Tests with a network error. +add_task(async function networkError() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + // This promise will be resolved when the client processes the network error. + let responsePromise = gClient.waitForNextResponse(); + + await MerinoTestUtils.server.withNetworkError(async () => { + await fetchAndCheckSuggestions({ expected: [] }); + }); + + // The client should have nulled out the timeout timer before `fetch()` + // returned. + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after fetch finished" + ); + + // Wait for the client to process the network error. + await responsePromise; + + Assert.equal( + gClient.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "network_error", + latencyRecorded: false, + client: gClient, + }); +}); + +// Tests with an HTTP error. +add_task(async function httpError() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response = { status: 500 }; + await fetchAndCheckSuggestions({ expected: [] }); + + Assert.equal( + gClient.lastFetchStatus, + "http_error", + "The request failed with an HTTP error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "http_error", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); +}); + +// Tests a client timeout. +add_task(async function clientTimeout() { + await doClientTimeoutTest({ + prefTimeoutMs: 200, + responseDelayMs: 400, + }); +}); + +// Tests a client timeout followed by an HTTP error. Only the timeout should be +// recorded. +add_task(async function clientTimeoutFollowedByHTTPError() { + MerinoTestUtils.server.response = { status: 500 }; + await doClientTimeoutTest({ + prefTimeoutMs: 200, + responseDelayMs: 400, + expectedResponseStatus: 500, + }); +}); + +// Tests a client timeout when a timeout value is passed to `fetch()`, which +// should override the value in the `merino.timeoutMs` pref. +add_task(async function timeoutPassedToFetch() { + // Set up a timeline like this: + // + // 1ms: The timeout passed to `fetch()` elapses + // 400ms: Merino returns a response + // 30000ms: The timeout in the pref elapses + // + // The expected behavior is that the 1ms timeout is hit, the request fails + // with a timeout, and Merino later returns a response. If the 1ms timeout is + // not hit, then Merino will return a response before the 30000ms timeout + // elapses and the request will complete successfully. + + await doClientTimeoutTest({ + prefTimeoutMs: 30000, + responseDelayMs: 400, + fetchArgs: { query: "search", timeoutMs: 1 }, + }); +}); + +async function doClientTimeoutTest({ + prefTimeoutMs, + responseDelayMs, + fetchArgs = { query: "search" }, + expectedResponseStatus = 200, +} = {}) { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let originalPrefTimeoutMs = UrlbarPrefs.get("merino.timeoutMs"); + UrlbarPrefs.set("merino.timeoutMs", prefTimeoutMs); + + // Make the server return a delayed response so the client times out waiting + // for it. + MerinoTestUtils.server.response.delay = responseDelayMs; + + let responsePromise = gClient.waitForNextResponse(); + await fetchAndCheckSuggestions({ args: fetchArgs, expected: [] }); + + Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out"); + + // The client should have nulled out the timeout timer. + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after fetch finished" + ); + + // The fetch controller should still exist because the fetch should remain + // ongoing. + Assert.ok( + gClient._test_fetchController, + "fetchController still exists after fetch finished" + ); + Assert.ok( + !gClient._test_fetchController.signal.aborted, + "fetchController is not aborted" + ); + + // The latency histogram should not be updated since the response has not been + // received. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "timeout", + latencyRecorded: false, + latencyStopwatchRunning: true, + client: gClient, + }); + + // Wait for the client to receive the response. + let httpResponse = await responsePromise; + Assert.ok(httpResponse, "Response was received"); + Assert.equal(httpResponse.status, expectedResponseStatus, "Response status"); + + // The client should have nulled out the fetch controller. + Assert.ok(!gClient._test_fetchController, "fetchController no longer exists"); + + // The `checkAndClearHistograms()` call above cleared the histograms. After + // that, nothing else should have been recorded for the response. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + UrlbarPrefs.set("merino.timeoutMs", originalPrefTimeoutMs); +} + +// By design, when a fetch times out, the client allows it to finish so we can +// record its latency. But when a second fetch starts before the first finishes, +// the client should abort the first so that there is at most one fetch at a +// time. +add_task(async function newFetchAbortsPrevious() { + let histograms = MerinoTestUtils.getAndClearHistograms(); + + // Make the server return a very delayed response so that it would time out + // and we can start a second fetch that will abort the first fetch. + MerinoTestUtils.server.response.delay = + 100 * UrlbarPrefs.get("merino.timeoutMs"); + + // Do the first fetch. + await fetchAndCheckSuggestions({ expected: [] }); + + // At this point, the timeout timer has fired, causing our `fetch()` call to + // return. However, the client's internal fetch should still be ongoing. + + Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out"); + + // The client should have nulled out the timeout timer. + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after first fetch finished" + ); + + // The fetch controller should still exist because the fetch should remain + // ongoing. + Assert.ok( + gClient._test_fetchController, + "fetchController still exists after first fetch finished" + ); + Assert.ok( + !gClient._test_fetchController.signal.aborted, + "fetchController is not aborted" + ); + + // The latency histogram should not be updated since the fetch is still + // ongoing. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "timeout", + latencyRecorded: false, + latencyStopwatchRunning: true, + client: gClient, + }); + + // Do the second fetch. This time don't delay the response. + delete MerinoTestUtils.server.response.delay; + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request finished successfully" + ); + + // The fetch was successful, so the client should have nulled out both + // properties. + Assert.ok( + !gClient._test_fetchController, + "fetchController does not exist after second fetch finished" + ); + Assert.strictEqual( + gClient._test_timeoutTimer, + null, + "timeoutTimer does not exist after second fetch finished" + ); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); +}); + +// The client should not include the `clientVariants` and `providers` search +// params when they are not set. +add_task(async function clientVariants_providers_notSet() { + UrlbarPrefs.set("merino.clientVariants", ""); + UrlbarPrefs.set("merino.providers", ""); + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + UrlbarPrefs.clear("merino.clientVariants"); + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `clientVariants` and `providers` search params +// when they are set using preferences. +add_task(async function clientVariants_providers_preferences() { + UrlbarPrefs.set("merino.clientVariants", "green"); + UrlbarPrefs.set("merino.providers", "pink"); + + await fetchAndCheckSuggestions({ + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.CLIENT_VARIANTS]: "green", + [SEARCH_PARAMS.PROVIDERS]: "pink", + }, + }, + ]); + + UrlbarPrefs.clear("merino.clientVariants"); + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `providers` search param when it's set by +// passing in the `providers` argument to `fetch()`. The argument should +// override the pref. This tests a single provider. +add_task(async function providers_arg_single() { + UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); + + await fetchAndCheckSuggestions({ + args: { query: "search", providers: ["argShouldBeUsed"] }, + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.PROVIDERS]: "argShouldBeUsed", + }, + }, + ]); + + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `providers` search param when it's set by +// passing in the `providers` argument to `fetch()`. The argument should +// override the pref. This tests multiple providers. +add_task(async function providers_arg_many() { + UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); + + await fetchAndCheckSuggestions({ + args: { query: "search", providers: ["one", "two", "three"] }, + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.PROVIDERS]: "one,two,three", + }, + }, + ]); + + UrlbarPrefs.clear("merino.providers"); +}); + +// The client should include the `providers` search param when it's set by +// passing in the `providers` argument to `fetch()` even when it's an empty +// array. The argument should override the pref. +add_task(async function providers_arg_empty() { + UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); + + await fetchAndCheckSuggestions({ + args: { query: "search", providers: [] }, + expected: EXPECTED_MERINO_SUGGESTIONS, + }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [SEARCH_PARAMS.PROVIDERS]: "", + }, + }, + ]); + + UrlbarPrefs.clear("merino.providers"); +}); + +// Passes invalid `providers` arguments to `fetch()`. +add_task(async function providers_arg_invalid() { + let providersValues = ["", "nonempty", {}]; + + for (let providers of providersValues) { + info("Calling fetch() with providers: " + JSON.stringify(providers)); + + // `Assert.throws()` doesn't seem to work with async functions... + let error; + try { + await gClient.fetch({ providers, query: "search" }); + } catch (e) { + error = e; + } + Assert.ok(error, "fetch() threw an error"); + Assert.equal( + error.message, + "providers must be an array if given", + "Expected error was thrown" + ); + } +}); + +// Tests setting the endpoint URL and query parameters via Nimbus. +add_task(async function nimbus() { + // Clear the endpoint pref so we know the URL is not being fetched from it. + let originalEndpointURL = UrlbarPrefs.get("merino.endpointURL"); + UrlbarPrefs.set("merino.endpointURL", ""); + + await UrlbarTestUtils.initNimbusFeature(); + + // First, with the endpoint pref set to an empty string, make sure no Merino + // suggestion are returned. + await fetchAndCheckSuggestions({ expected: [] }); + + // Now install an experiment that sets the endpoint and other Merino-related + // variables. Make sure a suggestion is returned and the request includes the + // correct query params. + + // `param`: The param name in the request URL + // `value`: The value to use for the param + // `variable`: The name of the Nimbus variable corresponding to the param + let expectedParams = [ + { + param: SEARCH_PARAMS.CLIENT_VARIANTS, + value: "test-client-variants", + variable: "merinoClientVariants", + }, + { + param: SEARCH_PARAMS.PROVIDERS, + value: "test-providers", + variable: "merinoProviders", + }, + ]; + + // Set up the Nimbus variable values to create the experiment with. + let experimentValues = { + merinoEndpointURL: MerinoTestUtils.server.url.toString(), + }; + for (let { variable, value } of expectedParams) { + experimentValues[variable] = value; + } + + await withExperiment(experimentValues, async () => { + await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS }); + + let params = { + [SEARCH_PARAMS.QUERY]: "search", + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }; + for (let { param, value } of expectedParams) { + params[param] = value; + } + MerinoTestUtils.server.checkAndClearRequests([{ params }]); + }); + + UrlbarPrefs.set("merino.endpointURL", originalEndpointURL); +}); + +async function fetchAndCheckSuggestions({ + expected, + args = { + query: "search", + }, +}) { + let actual = await gClient.fetch(args); + Assert.deepEqual(actual, expected, "Expected suggestions"); + gClient.resetSession(); +} + +async function withExperiment(values, callback) { + let { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper( + ExperimentFakes.recipe("mock-experiment", { + active: true, + branches: [ + { + slug: "treatment", + features: [ + { + featureId: NimbusFeatures.urlbar.featureId, + value: { + enabled: true, + ...values, + }, + }, + ], + }, + ], + }) + ); + await enrollmentPromise; + await callback(); + await doExperimentCleanup(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js new file mode 100644 index 0000000000..b8d62062c0 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js @@ -0,0 +1,402 @@ +/* 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/. */ + +// Test for MerinoClient sessions. + +"use strict"; + +const { MerinoClient } = ChromeUtils.importESModule( + "resource:///modules/MerinoClient.sys.mjs" +); + +const { SEARCH_PARAMS } = MerinoClient; + +let gClient; + +add_setup(async () => { + gClient = new MerinoClient(); + await MerinoTestUtils.server.start(); +}); + +// In a single session, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleSession() { + for (let i = 0; i < 3; i++) { + let query = "search" + i; + await gClient.fetch({ query }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// Different sessions should use different session IDs and the sequence number +// should be reset. +add_task(async function manySessions() { + for (let i = 0; i < 3; i++) { + let query = "search" + i; + await gClient.fetch({ query }); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + gClient.resetSession(); + } +}); + +// Tests two consecutive fetches: +// +// 1. Start a fetch +// 2. Wait for the mock Merino server to receive the request +// 3. Start a second fetch before the client receives the response +// +// The first fetch will be canceled by the second but the sequence number in the +// second fetch should still be incremented. +add_task(async function twoFetches_wait() { + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first fetch but don't wait for it to finish. + let requestPromise = MerinoTestUtils.server.waitForNextRequest(); + let query1 = "search" + i; + gClient.fetch({ query: query1 }); + + // Wait until the first request is received before starting the second + // fetch, which will cancel the first. The response doesn't need to be + // delayed, so remove it to make the test run faster. + await requestPromise; + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The sequence number should have been incremented for each fetch. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// Tests two consecutive fetches: +// +// 1. Start a fetch +// 2. Immediately start a second fetch +// +// The first fetch will be canceled by the second but the sequence number in the +// second fetch should still be incremented. +add_task(async function twoFetches_immediate() { + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = + 100 * UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first fetch but don't wait for it to finish. + let query1 = "search" + i; + gClient.fetch({ query: query1 }); + + // Immediately do a second fetch that cancels the first. The response + // doesn't need to be delayed, so remove it to make the test run faster. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The sequence number should have been incremented for each fetch, but the + // first won't have reached the server since it was immediately canceled. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When a network error occurs, the sequence number should still be incremented. +add_task(async function networkError() { + for (let i = 0; i < 3; i++) { + // Do a fetch that fails with a network error. + let query1 = "search" + i; + await MerinoTestUtils.server.withNetworkError(async () => { + await gClient.fetch({ query: query1 }); + }); + + Assert.equal( + gClient.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + + // Do another fetch that successfully finishes. + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The request completed successfully" + ); + + // Only the second request should have been received but the sequence number + // should have been incremented for each. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the server returns a response with an HTTP error, the sequence number +// should be incremented. +add_task(async function httpError() { + for (let i = 0; i < 3; i++) { + // Do a fetch that fails with an HTTP error. + MerinoTestUtils.server.response.status = 500; + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "http_error", + "The last request failed with a network error" + ); + + // Do another fetch that successfully finishes. + MerinoTestUtils.server.response.status = 200; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + // Both requests should have been received and the sequence number should + // have been incremented for each. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + + MerinoTestUtils.server.reset(); + } + + gClient.resetSession(); +}); + +// When the client times out waiting for a response but later receives it and no +// other fetch happens in the meantime, the sequence number should be +// incremented. +add_task(async function clientTimeout_wait() { + for (let i = 0; i < 3; i++) { + // Do a fetch that causes the client to time out. + MerinoTestUtils.server.response.delay = + 2 * UrlbarPrefs.get("merino.timeoutMs"); + let responsePromise = gClient.waitForNextResponse(); + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "timeout", + "The last request failed with a client timeout" + ); + + // Wait for the client to receive the response. + await responsePromise; + + // Do another fetch that successfully finishes. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the client times out waiting for a response and a second fetch starts +// before the response is received, the first fetch should be canceled but the +// sequence number should still be incremented. +add_task(async function clientTimeout_canceled() { + for (let i = 0; i < 3; i++) { + // Do a fetch that causes the client to time out. + MerinoTestUtils.server.response.delay = + 2 * UrlbarPrefs.get("merino.timeoutMs"); + let query1 = "search" + i; + await gClient.fetch({ query: query1 }); + + Assert.equal( + gClient.lastFetchStatus, + "timeout", + "The last request failed with a client timeout" + ); + + // Do another fetch that successfully finishes. + delete MerinoTestUtils.server.response.delay; + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + Assert.equal( + gClient.lastFetchStatus, + "success", + "The last request completed successfully" + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + gClient.resetSession(); +}); + +// When the session times out, the next fetch should use a new session ID and +// the sequence number should be reset. +add_task(async function sessionTimeout() { + // Set the session timeout to something reasonable to test. + let originalTimeoutMs = gClient.sessionTimeoutMs; + gClient.sessionTimeoutMs = 500; + + // Do a fetch. + let query1 = "search"; + await gClient.fetch({ query: query1 }); + + // Wait for the session to time out. + await gClient.waitForNextSessionReset(); + + Assert.strictEqual( + gClient.sessionID, + null, + "sessionID is null after session timeout" + ); + Assert.strictEqual( + gClient.sequenceNumber, + 0, + "sequenceNumber is zero after session timeout" + ); + Assert.strictEqual( + gClient._test_sessionTimer, + null, + "sessionTimer is null after session timeout" + ); + + // Do another fetch. + let query2 = query1 + "again"; + await gClient.fetch({ query: query2 }); + + // The second request's sequence number should be zero due to the session + // timeout. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [SEARCH_PARAMS.QUERY]: query1, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + { + params: { + [SEARCH_PARAMS.QUERY]: query2, + [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + Assert.ok( + gClient.sessionID, + "sessionID is non-null after first request in a new session" + ); + Assert.equal( + gClient.sequenceNumber, + 1, + "sequenceNumber is one after first request in a new session" + ); + Assert.ok( + gClient._test_sessionTimer, + "sessionTimer is non-null after first request in a new session" + ); + + gClient.sessionTimeoutMs = originalTimeoutMs; + gClient.resetSession(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js new file mode 100644 index 0000000000..e4c145aabb --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js @@ -0,0 +1,1661 @@ +/* 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/. */ + +// Basic tests for the quick suggest provider using the remote settings source. +// See also test_quicksuggest_merino.js. + +"use strict"; + +const TELEMETRY_REMOTE_SETTINGS_LATENCY = + "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS"; + +const SPONSORED_SEARCH_STRING = "amp"; +const NONSPONSORED_SEARCH_STRING = "wikipedia"; +const SPONSORED_AND_NONSPONSORED_SEARCH_STRING = "sponsored and non-sponsored"; + +const HTTP_SEARCH_STRING = "http prefix"; +const HTTPS_SEARCH_STRING = "https prefix"; +const PREFIX_SUGGESTIONS_STRIPPED_URL = "example.com/prefix-test"; + +const { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = QuickSuggest; +const TIMESTAMP_SEARCH_STRING = "timestamp"; +const TIMESTAMP_SUGGESTION_URL = `http://example.com/timestamp-${TIMESTAMP_TEMPLATE}`; +const TIMESTAMP_SUGGESTION_CLICK_URL = `http://click.reporting.test.com/timestamp-${TIMESTAMP_TEMPLATE}-foo`; + +const REMOTE_SETTINGS_RESULTS = [ + QuickSuggestTestUtils.ampRemoteSettings({ + keywords: [ + SPONSORED_SEARCH_STRING, + SPONSORED_AND_NONSPONSORED_SEARCH_STRING, + ], + }), + QuickSuggestTestUtils.wikipediaRemoteSettings({ + keywords: [ + NONSPONSORED_SEARCH_STRING, + SPONSORED_AND_NONSPONSORED_SEARCH_STRING, + ], + }), + { + id: 3, + url: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + title: "HTTP Suggestion", + keywords: [HTTP_SEARCH_STRING], + click_url: "http://example.com/http-click", + impression_url: "http://example.com/http-impression", + advertiser: "HttpAdvertiser", + iab_category: "22 - Shopping", + icon: "1234", + }, + { + id: 4, + url: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + title: "https suggestion", + keywords: [HTTPS_SEARCH_STRING], + click_url: "http://click.reporting.test.com/prefix", + impression_url: "http://impression.reporting.test.com/prefix", + advertiser: "TestAdvertiserPrefix", + iab_category: "22 - Shopping", + icon: "1234", + }, + { + id: 5, + url: TIMESTAMP_SUGGESTION_URL, + title: "Timestamp suggestion", + keywords: [TIMESTAMP_SEARCH_STRING], + click_url: TIMESTAMP_SUGGESTION_CLICK_URL, + impression_url: "http://impression.reporting.test.com/timestamp", + advertiser: "TestAdvertiserTimestamp", + iab_category: "22 - Shopping", + icon: "1234", + }, +]; + +function expectedNonSponsoredResult() { + return makeWikipediaResult({ + blockId: 2, + }); +} + +function expectedSponsoredResult() { + return makeAmpResult(); +} + +function expectedSponsoredPriorityResult() { + return { + ...expectedSponsoredResult(), + isBestMatch: true, + suggestedIndex: 1, + isSuggestedIndexRelativeToGroup: false, + }; +} + +function expectedHttpResult() { + let suggestion = REMOTE_SETTINGS_RESULTS[2]; + return makeAmpResult({ + keyword: HTTP_SEARCH_STRING, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + advertiser: suggestion.advertiser, + }); +} + +function expectedHttpsResult() { + let suggestion = REMOTE_SETTINGS_RESULTS[3]; + return makeAmpResult({ + keyword: HTTPS_SEARCH_STRING, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + advertiser: suggestion.advertiser, + }); +} + +add_setup(async function init() { + // Install a default test engine. + let engine = await addTestSuggestionsEngine(); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + const testDataTypeResults = [ + Object.assign({}, REMOTE_SETTINGS_RESULTS[0], { title: "test-data-type" }), + ]; + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + { + type: "test-data-type", + attachment: testDataTypeResults, + }, + ], + }); +}); + +add_task(async function telemetryType_sponsored() { + Assert.equal( + QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({ + is_sponsored: true, + }), + "adm_sponsored", + "Telemetry type should be 'adm_sponsored'" + ); +}); + +add_task(async function telemetryType_nonsponsored() { + Assert.equal( + QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({ + is_sponsored: false, + }), + "adm_nonsponsored", + "Telemetry type should be 'adm_nonsponsored'" + ); + Assert.equal( + QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({}), + "adm_nonsponsored", + "Telemetry type should be 'adm_nonsponsored' if `is_sponsored` not defined" + ); +}); + +// Tests with only non-sponsored suggestions enabled with a matching search +// string. +add_tasks_with_rust(async function nonsponsoredOnly_match() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedNonSponsoredResult()], + }); + + // The title should include the full keyword and em dash, and the part of the + // title that the search string does not match should be highlighted. + let result = context.results[0]; + Assert.equal( + result.title, + `${NONSPONSORED_SEARCH_STRING} — Wikipedia Suggestion`, + "result.title should be correct" + ); + Assert.deepEqual( + result.titleHighlights, + [], + "result.titleHighlights should be correct" + ); +}); + +// Tests with only non-sponsored suggestions enabled with a non-matching search +// string. +add_tasks_with_rust(async function nonsponsoredOnly_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with only sponsored suggestions enabled with a matching search string. +add_tasks_with_rust(async function sponsoredOnly_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + // The title should include the full keyword and em dash, and the part of the + // title that the search string does not match should be highlighted. + let result = context.results[0]; + Assert.equal( + result.title, + `${SPONSORED_SEARCH_STRING} — Amp Suggestion`, + "result.title should be correct" + ); + Assert.deepEqual( + result.titleHighlights, + [], + "result.titleHighlights should be correct" + ); +}); + +// Tests with only sponsored suggestions enabled with a non-matching search +// string. +add_tasks_with_rust(async function sponsoredOnly_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that matches the sponsored suggestion. +add_tasks_with_rust(async function both_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that matches the non-sponsored suggestion. +add_tasks_with_rust(async function both_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedNonSponsoredResult()], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that doesn't match either suggestion. +add_tasks_with_rust(async function both_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext("this doesn't match anything", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both the main and sponsored prefs disabled with a search string +// that matches the sponsored suggestion. +add_tasks_with_rust(async function neither_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Tests with both the main and sponsored prefs disabled with a search string +// that matches the non-sponsored suggestion. +add_tasks_with_rust(async function neither_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ context, matches: [] }); +}); + +// Search string matching should be case insensitive and ignore leading spaces. +add_tasks_with_rust(async function caseInsensitiveAndLeadingSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(" " + SPONSORED_SEARCH_STRING.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); +}); + +// The provider should not be active for search strings that are empty or +// contain only spaces. +add_tasks_with_rust(async function emptySearchStringsAndSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let searchStrings = ["", " ", " ", " "]; + for (let str of searchStrings) { + let msg = JSON.stringify(str) + ` (length = ${str.length})`; + info("Testing search string: " + msg); + + let context = createContext(str, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + Assert.ok( + !UrlbarProviderQuickSuggest.isActive(context), + "Provider should not be active for search string: " + msg + ); + } +}); + +// Results should be returned even when `browser.search.suggest.enabled` is +// false. +add_tasks_with_rust(async function browser_search_suggest_enabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); +}); + +// Results should be returned even when `browser.urlbar.suggest.searches` is +// false. +add_tasks_with_rust(async function browser_search_suggest_enabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.searches", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + UrlbarPrefs.clear("suggest.searches"); +}); + +// Neither sponsored nor non-sponsored results should appear in private contexts +// even when suggestions in private windows are enabled. +add_tasks_with_rust(async function privateContext() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + for (let privateSuggestionsEnabled of [true, false]) { + UrlbarPrefs.set( + "browser.search.suggest.enabled.private", + privateSuggestionsEnabled + ); + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: true, + }); + await check_results({ + context, + matches: [], + }); + } + + UrlbarPrefs.clear("browser.search.suggest.enabled.private"); +}); + +// When search suggestions come before general results and the only general +// result is a quick suggest result, it should come last. +add_tasks_with_rust(async function suggestionsBeforeGeneral_only() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + expectedSponsoredResult(), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); +}); + +// When search suggestions come before general results and there are other +// general results besides quick suggest, the quick suggest result should come +// last. +add_tasks_with_rust(async function suggestionsBeforeGeneral_others() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", true); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + + // Add some history that will match our query below. + let maxResults = UrlbarPrefs.get("maxRichResults"); + let historyResults = []; + for (let i = 0; i < maxResults; i++) { + let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i; + historyResults.push( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + await PlacesTestUtils.addVisits(url); + } + historyResults = historyResults.reverse().slice(0, historyResults.length - 4); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ...historyResults, + expectedSponsoredResult(), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + await PlacesUtils.history.clear(); +}); + +// When general results come before search suggestions and the only general +// result is a quick suggest result, it should come before suggestions. +add_tasks_with_rust(async function generalBeforeSuggestions_only() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + expectedSponsoredResult(), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); +}); + +// When general results come before search suggestions and there are other +// general results besides quick suggest, the quick suggest result should be the +// last general result. +add_tasks_with_rust(async function generalBeforeSuggestions_others() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("browser.search.suggest.enabled", true); + UrlbarPrefs.set("suggest.searches", true); + UrlbarPrefs.set("showSearchSuggestionsFirst", false); + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false }); + + // Add some history that will match our query below. + let maxResults = UrlbarPrefs.get("maxRichResults"); + let historyResults = []; + for (let i = 0; i < maxResults; i++) { + let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i; + historyResults.push( + makeVisitResult(context, { + uri: url, + title: "test visit for " + url, + }) + ); + await PlacesTestUtils.addVisits(url); + } + historyResults = historyResults.reverse().slice(0, historyResults.length - 4); + + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: SPONSORED_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }), + ...historyResults, + expectedSponsoredResult(), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " foo", + engineName: Services.search.defaultEngine.name, + }), + makeSearchResult(context, { + query: SPONSORED_SEARCH_STRING, + suggestion: SPONSORED_SEARCH_STRING + " bar", + engineName: Services.search.defaultEngine.name, + }), + ], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); + UrlbarPrefs.clear("suggest.searches"); + UrlbarPrefs.clear("showSearchSuggestionsFirst"); + await PlacesUtils.history.clear(); +}); + +add_tasks_with_rust(async function dedupeAgainstURL_samePrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: expectedHttpResult(), + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_tasks_with_rust(async function dedupeAgainstURL_higherPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTPS_SEARCH_STRING, + expectedQuickSuggestResult: expectedHttpsResult(), + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_tasks_with_rust(async function dedupeAgainstURL_lowerPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: expectedHttpResult(), + otherPrefix: "https://", + expectOther: true, + }); +}); + +/** + * Tests how the muxer dedupes URL results against quick suggest results. + * Depending on prefix rank, quick suggest results should be preferred over + * other URL results with the same stripped URL: Other results should be + * discarded when their prefix rank is lower than the prefix rank of the quick + * suggest. They should not be discarded when their prefix rank is higher, and + * in that case both results should be included. + * + * This function adds a visit to the URL formed by the given `otherPrefix` and + * `PREFIX_SUGGESTIONS_STRIPPED_URL`. The visit's title will be set to the given + * `searchString` so that both the visit and the quick suggest will match it. + * + * @param {object} options + * Options object. + * @param {string} options.searchString + * The search string that should trigger one of the mock prefix-test quick + * suggest results. + * @param {object} options.expectedQuickSuggestResult + * The expected quick suggest result. + * @param {string} options.otherPrefix + * The visit will be created with a URL with this prefix, e.g., "http://". + * @param {boolean} options.expectOther + * Whether the visit result should appear in the final results. + */ +async function doDedupeAgainstURLTest({ + searchString, + expectedQuickSuggestResult, + otherPrefix, + expectOther, +}) { + // Disable search suggestions. + UrlbarPrefs.set("suggest.searches", false); + + // Add a visit that will match our query below. + let otherURL = otherPrefix + PREFIX_SUGGESTIONS_STRIPPED_URL; + await PlacesTestUtils.addVisits({ uri: otherURL, title: searchString }); + + // First, do a search with quick suggest disabled to make sure the search + // string matches the visit. + info("Doing first query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + let context = createContext(searchString, { isPrivate: false }); + await check_results({ + context, + matches: [ + makeSearchResult(context, { + heuristic: true, + query: searchString, + engineName: Services.search.defaultEngine.name, + }), + makeVisitResult(context, { + uri: otherURL, + title: searchString, + }), + ], + }); + + // Now do another search with quick suggest enabled. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + context = createContext(searchString, { isPrivate: false }); + + let expectedResults = [ + makeSearchResult(context, { + heuristic: true, + query: searchString, + engineName: Services.search.defaultEngine.name, + }), + ]; + if (expectOther) { + expectedResults.push( + makeVisitResult(context, { + uri: otherURL, + title: searchString, + }) + ); + } + expectedResults.push(expectedQuickSuggestResult); + + info("Doing second query"); + await check_results({ context, matches: expectedResults }); + + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + await QuickSuggestTestUtils.forceSync(); + + UrlbarPrefs.clear("suggest.searches"); + await PlacesUtils.history.clear(); +} + +// Tests the remote settings latency histogram. +add_task( + { + // Not supported by the Rust backend. + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function latencyTelemetry() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let histogram = Services.telemetry.getHistogramById( + TELEMETRY_REMOTE_SETTINGS_LATENCY + ); + histogram.clear(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedSponsoredResult()], + }); + + // In the latency histogram, there should be a single value across all + // buckets. + Assert.deepEqual( + Object.values(histogram.snapshot().values).filter(v => v > 0), + [1], + "Latency histogram updated after search" + ); + Assert.ok( + !TelemetryStopwatch.running(TELEMETRY_REMOTE_SETTINGS_LATENCY, context), + "Stopwatch not running after search" + ); + } +); + +// Tests setup and teardown of the remote settings client depending on whether +// quick suggest is enabled. +add_task( + { + // Not supported by the Rust backend. + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function setupAndTeardown() { + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is enabled initially" + ); + + // Disable the suggest prefs so the settings client starts out torn down. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling suggest prefs" + ); + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend remains enabled" + ); + + // Setting one of the suggest prefs should cause the client to be set up. We + // assume all previous tasks left `quicksuggest.enabled` true (from the init + // task). + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after enabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client remains non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client remains non-null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("quicksuggest.enabled", false); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after disabling quicksuggest.enabled" + ); + + UrlbarPrefs.set("quicksuggest.enabled", true); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after re-enabling quicksuggest.enabled" + ); + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is enabled after re-enabling quicksuggest.enabled" + ); + + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client is null after enabling the Rust backend" + ); + Assert.ok( + !QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is disabled after enabling the Rust backend" + ); + + UrlbarPrefs.clear("quicksuggest.rustEnabled"); + Assert.ok( + QuickSuggest.jsBackend.rs, + "Settings client is non-null after disabling the Rust backend" + ); + Assert.ok( + QuickSuggest.jsBackend.isEnabled, + "Remote settings backend is enabled after disabling the Rust backend" + ); + + // Leave the prefs in the same state as when the task started. + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + UrlbarPrefs.set("quicksuggest.enabled", true); + Assert.ok( + !QuickSuggest.jsBackend.rs, + "Settings client remains null at end of task" + ); + } +); + +// Timestamp templates in URLs should be replaced with real timestamps. +add_tasks_with_rust(async function timestamps() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + // Do a search. + let context = createContext(TIMESTAMP_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); +}); + +// Real quick suggest URLs include a timestamp template that +// UrlbarProviderQuickSuggest fills in when it fetches suggestions. When the +// user picks a quick suggest, its URL with its particular timestamp is added to +// history. If the user triggers the quick suggest again later, its new +// timestamp may be different from the one in the user's history. In that case, +// the two URLs should be treated as dupes and only the quick suggest should be +// shown, not the URL from history. +add_tasks_with_rust(async function dedupeAgainstURL_timestamps() { + // Disable search suggestions. + UrlbarPrefs.set("suggest.searches", false); + + // Add a visit that will match the query below and dupe the quick suggest. + let dupeURL = TIMESTAMP_SUGGESTION_URL.replace( + TIMESTAMP_TEMPLATE, + "2013051113" + ); + + // Add other visits that will match the query and almost dupe the quick + // suggest but not quite because they have invalid timestamps. + let badTimestamps = [ + // not numeric digits + "x".repeat(TIMESTAMP_LENGTH), + // too few digits + "5".repeat(TIMESTAMP_LENGTH - 1), + // empty string, too few digits + "", + ]; + let badTimestampURLs = badTimestamps.map(str => + TIMESTAMP_SUGGESTION_URL.replace(TIMESTAMP_TEMPLATE, str) + ); + + await PlacesTestUtils.addVisits( + [dupeURL, ...badTimestampURLs].map(uri => ({ + uri, + title: TIMESTAMP_SEARCH_STRING, + })) + ); + + // First, do a search with quick suggest disabled to make sure the search + // string matches all the other URLs. + info("Doing first query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + let context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false }); + + let expectedHeuristic = makeSearchResult(context, { + heuristic: true, + query: TIMESTAMP_SEARCH_STRING, + engineName: Services.search.defaultEngine.name, + }); + let expectedDupeResult = makeVisitResult(context, { + uri: dupeURL, + title: TIMESTAMP_SEARCH_STRING, + }); + let expectedBadTimestampResults = [...badTimestampURLs].reverse().map(uri => + makeVisitResult(context, { + uri, + title: TIMESTAMP_SEARCH_STRING, + }) + ); + + await check_results({ + context, + matches: [ + expectedHeuristic, + ...expectedBadTimestampResults, + expectedDupeResult, + ], + }); + + // Now do another search with quick suggest enabled. + info("Doing second query"); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false }); + + let expectedQuickSuggest = makeAmpResult({ + originalUrl: TIMESTAMP_SUGGESTION_URL, + keyword: TIMESTAMP_SEARCH_STRING, + title: "Timestamp suggestion", + impressionUrl: "http://impression.reporting.test.com/timestamp", + blockId: 5, + advertiser: "TestAdvertiserTimestamp", + iabCategory: "22 - Shopping", + }); + + let expectedResults = [ + expectedHeuristic, + ...expectedBadTimestampResults, + expectedQuickSuggest, + ]; + + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context); + info("Actual results: " + JSON.stringify(context.results)); + + Assert.equal( + context.results.length, + expectedResults.length, + "Found the expected number of results" + ); + + function getPayload(result, keysToIgnore = []) { + let payload = {}; + for (let [key, value] of Object.entries(result.payload)) { + if (value !== undefined && !keysToIgnore.includes(key)) { + payload[key] = value; + } + } + return payload; + } + + // Check actual vs. expected result properties. + for (let i = 0; i < expectedResults.length; i++) { + let actual = context.results[i]; + let expected = expectedResults[i]; + info( + `Comparing results at index ${i}:` + + " actual=" + + JSON.stringify(actual) + + " expected=" + + JSON.stringify(expected) + ); + Assert.equal( + actual.type, + expected.type, + `result.type at result index ${i}` + ); + Assert.equal( + actual.source, + expected.source, + `result.source at result index ${i}` + ); + Assert.equal( + actual.heuristic, + expected.heuristic, + `result.heuristic at result index ${i}` + ); + + // Check payloads except for the last result, which should be the quick + // suggest. + if (i != expectedResults.length - 1) { + Assert.deepEqual( + getPayload(context.results[i]), + getPayload(expectedResults[i]), + "Payload at index " + i + ); + } + } + + // Check the quick suggest's payload excluding the timestamp-related + // properties. + let actualQuickSuggest = context.results[context.results.length - 1]; + let timestampKeys = [ + "displayUrl", + "sponsoredClickUrl", + "url", + "urlTimestampIndex", + ]; + Assert.deepEqual( + getPayload(actualQuickSuggest, timestampKeys), + getPayload(expectedQuickSuggest, timestampKeys), + "Quick suggest payload excluding timestamp-related keys" + ); + + // Now check the timestamps in the payload. + QuickSuggestTestUtils.assertTimestampsReplaced(actualQuickSuggest, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); + + // Clean up. + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + await QuickSuggestTestUtils.forceSync(); + + UrlbarPrefs.clear("suggest.searches"); + await PlacesUtils.history.clear(); +}); + +// Tests the API for blocking suggestions and the backing pref. +add_task(async function blockedSuggestionsAPI() { + // Start with no blocked suggestions. + await QuickSuggest.blockedSuggestions.clear(); + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is empty" + ); + Assert.equal( + UrlbarPrefs.get("quicksuggest.blockedDigests"), + "", + "quicksuggest.blockedDigests is an empty string" + ); + + // Make some URLs. + let urls = []; + for (let i = 0; i < 3; i++) { + urls.push("http://example.com/" + i); + } + + // Block each URL in turn and make sure previously blocked URLs are still + // blocked and the remaining URLs are not blocked. + for (let i = 0; i < urls.length; i++) { + await QuickSuggest.blockedSuggestions.add(urls[i]); + for (let j = 0; j < urls.length; j++) { + Assert.equal( + await QuickSuggest.blockedSuggestions.has(urls[j]), + j <= i, + `Suggestion at index ${j} is blocked or not as expected` + ); + } + } + + // Make sure all URLs are blocked for good measure. + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + + // Check `blockedSuggestions._test_digests` and `quicksuggest.blockedDigests`. + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + let array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + Assert.ok(Array.isArray(array), "Parsed value of pref is an array"); + Assert.equal(array.length, urls.length, "Array has correct length"); + + // Write some junk to `quicksuggest.blockedDigests`. + // `blockedSuggestions._test_digests` should not be changed and all previously + // blocked URLs should remain blocked. + UrlbarPrefs.set("quicksuggest.blockedDigests", "not a json array"); + await QuickSuggest.blockedSuggestions._test_readyPromise; + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion remains blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests still has correct size" + ); + + // Block a new URL. All URLs should remain blocked and the pref should be + // updated. + let newURL = "http://example.com/new-block"; + await QuickSuggest.blockedSuggestions.add(newURL); + urls.push(newURL); + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + Assert.ok(Array.isArray(array), "Parsed value of pref is an array"); + Assert.equal(array.length, urls.length, "Array has correct length"); + + // Add a new URL digest directly to the JSON'ed array in the pref. + newURL = "http://example.com/direct-to-pref"; + urls.push(newURL); + array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests")); + array.push(await QuickSuggest.blockedSuggestions._test_getDigest(newURL)); + UrlbarPrefs.set("quicksuggest.blockedDigests", JSON.stringify(array)); + await QuickSuggest.blockedSuggestions._test_readyPromise; + + // All URLs should remain blocked and the new URL should be blocked. + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + urls.length, + "blockedSuggestions._test_digests has correct size" + ); + + // Clear the pref. All URLs should be unblocked. + UrlbarPrefs.clear("quicksuggest.blockedDigests"); + await QuickSuggest.blockedSuggestions._test_readyPromise; + for (let url of urls) { + Assert.ok( + !(await QuickSuggest.blockedSuggestions.has(url)), + `Suggestion is no longer blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is now empty" + ); + + // Block all the URLs again and test `blockedSuggestions.clear()`. + for (let url of urls) { + await QuickSuggest.blockedSuggestions.add(url); + } + for (let url of urls) { + Assert.ok( + await QuickSuggest.blockedSuggestions.has(url), + `Suggestion is blocked: ${url}` + ); + } + await QuickSuggest.blockedSuggestions.clear(); + for (let url of urls) { + Assert.ok( + !(await QuickSuggest.blockedSuggestions.has(url)), + `Suggestion is no longer blocked: ${url}` + ); + } + Assert.equal( + QuickSuggest.blockedSuggestions._test_digests.size, + 0, + "blockedSuggestions._test_digests is now empty" + ); +}); + +// Tests blocking real `UrlbarResult`s. +add_tasks_with_rust(async function block() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + let tests = [ + // [suggestion, expected result] + [REMOTE_SETTINGS_RESULTS[0], expectedSponsoredResult()], + [REMOTE_SETTINGS_RESULTS[1], expectedNonSponsoredResult()], + [REMOTE_SETTINGS_RESULTS[2], expectedHttpResult()], + [REMOTE_SETTINGS_RESULTS[3], expectedHttpsResult()], + ]; + + for (let [suggestion, expectedResult] of tests) { + info("Testing suggestion: " + JSON.stringify(suggestion)); + + // Do a search to get a real `UrlbarResult` created for the suggestion. + let context = createContext(suggestion.keywords[0], { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedResult], + }); + + // Block it. + await QuickSuggest.blockedSuggestions.add(context.results[0].payload.url); + + // Do another search. The result shouldn't be added. + await check_results({ + context: createContext(suggestion.keywords[0], { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + await QuickSuggest.blockedSuggestions.clear(); + } +}); + +// Tests blocking a real `UrlbarResult` whose URL has a timestamp template. +add_tasks_with_rust(async function block_timestamp() { + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + await QuickSuggestTestUtils.forceSync(); + + // Do a search. + let context = createContext(TIMESTAMP_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController(); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: TIMESTAMP_SUGGESTION_URL, + sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL, + }); + + Assert.ok(result.payload.originalUrl, "The actual result has an originalUrl"); + Assert.equal( + result.payload.originalUrl, + REMOTE_SETTINGS_RESULTS[4].url, + "The actual result's originalUrl should be the raw suggestion URL with a timestamp template" + ); + + // Block the result. + await QuickSuggest.blockedSuggestions.add(result.payload.originalUrl); + + // Do another search. The result shouldn't be added. + await check_results({ + context: createContext(TIMESTAMP_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await QuickSuggest.blockedSuggestions.clear(); +}); + +// Makes sure remote settings data is fetched using the correct `type` based on +// the value of the `quickSuggestRemoteSettingsDataType` Nimbus variable. +add_task( + { + // Not supported by the Rust backend. + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function remoteSettingsDataType() { + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await QuickSuggestTestUtils.forceSync(); + + for (let dataType of [undefined, "test-data-type"]) { + // Set up a mock Nimbus rollout with the data type. + let value = {}; + if (dataType) { + value.quickSuggestRemoteSettingsDataType = dataType; + } + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(value); + + // Make the result for test data type. + let expected = expectedSponsoredResult(); + if (dataType) { + expected = JSON.parse(JSON.stringify(expected)); + expected.payload.title = dataType; + } + + // Re-sync. + await QuickSuggestTestUtils.forceSync(); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expected], + }); + + await cleanUpNimbus(); + } + } +); + +add_tasks_with_rust(async function sponsoredPriority_normal() { + await doSponsoredPriorityTest({ + searchWord: SPONSORED_SEARCH_STRING, + remoteSettingsData: [REMOTE_SETTINGS_RESULTS[0]], + expectedMatches: [expectedSponsoredPriorityResult()], + }); +}); + +add_tasks_with_rust(async function sponsoredPriority_nonsponsoredSuggestion() { + // Not affect to except sponsored suggestion. + await doSponsoredPriorityTest({ + searchWord: NONSPONSORED_SEARCH_STRING, + remoteSettingsData: [REMOTE_SETTINGS_RESULTS[1]], + expectedMatches: [expectedNonSponsoredResult()], + }); +}); + +add_tasks_with_rust(async function sponsoredPriority_sponsoredIndex() { + await doSponsoredPriorityTest({ + nimbusSettings: { quickSuggestSponsoredIndex: 2 }, + searchWord: SPONSORED_SEARCH_STRING, + remoteSettingsData: [REMOTE_SETTINGS_RESULTS[0]], + expectedMatches: [expectedSponsoredPriorityResult()], + }); +}); + +add_tasks_with_rust(async function sponsoredPriority_position() { + await doSponsoredPriorityTest({ + nimbusSettings: { quickSuggestAllowPositionInSuggestions: true }, + searchWord: SPONSORED_SEARCH_STRING, + remoteSettingsData: [ + Object.assign({}, REMOTE_SETTINGS_RESULTS[0], { position: 2 }), + ], + expectedMatches: [expectedSponsoredPriorityResult()], + }); +}); + +async function doSponsoredPriorityTest({ + remoteSettingsConfig = {}, + nimbusSettings = {}, + searchWord, + remoteSettingsData, + expectedMatches, +}) { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + ...nimbusSettings, + quickSuggestSponsoredPriority: true, + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: remoteSettingsData, + }, + ]); + await QuickSuggestTestUtils.setConfig(remoteSettingsConfig); + + await check_results({ + context: createContext(searchWord, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: expectedMatches, + }); + + await cleanUpNimbusEnable(); +} + +// When a Suggest best match and a tab-to-search (TTS) are shown in the same +// search, both will have a `suggestedIndex` value of 1. The TTS should appear +// first. +add_tasks_with_rust(async function tabToSearch() { + // We'll use a sponsored priority result as the best match result. Different + // types of Suggest results can appear as best matches, and they all should + // have the same behavior. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + Services.prefs.setBoolPref( + "browser.urlbar.quicksuggest.sponsoredPriority", + true + ); + + // Disable tab-to-search onboarding results so we get a regular TTS result, + // which we can test a little more easily with `makeSearchResult()`. + UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 0); + + // Disable search suggestions so we don't need to expect them below. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + // Install a test engine. The main part of its domain name needs to match the + // best match result too so we can trigger both its TTS and the best match. + let engineURL = `https://foo.${SPONSORED_SEARCH_STRING}.com/`; + let extension = await SearchTestUtils.installSearchExtension( + { + name: "Test", + search_url: engineURL, + }, + { skipUnload: true } + ); + let engine = Services.search.getEngineByName("Test"); + + // Also need to add a visit to trigger TTS. + await PlacesTestUtils.addVisits(engineURL); + + let context = createContext(SPONSORED_SEARCH_STRING, { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + // search heuristic + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.getIconURL(), + heuristic: true, + }), + // tab to search + makeSearchResult(context, { + engineName: engine.name, + engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS, + uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain), + providesSearchMode: true, + query: "", + providerName: "TabToSearch", + satisfiesAutofillThreshold: true, + }), + // Suggest best match + expectedSponsoredPriorityResult(), + // visit + makeVisitResult(context, { + uri: engineURL, + title: `test visit for ${engineURL}`, + }), + ], + }); + + await cleanupPlaces(); + await extension.unload(); + + UrlbarPrefs.clear("tabToSearch.onboard.interactionsLeft"); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + Services.prefs.clearUserPref("browser.urlbar.quicksuggest.sponsoredPriority"); +}); + +// `suggestion.position` should be ignored when the suggestion is a best match. +add_tasks_with_rust(async function position() { + // We'll use a sponsored priority result as the best match result. Different + // types of Suggest results can appear as best matches, and they all should + // have the same behavior. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + + Services.prefs.setBoolPref( + "browser.urlbar.quicksuggest.sponsoredPriority", + true + ); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + // Set the remote settings data with a suggestion containing a position. + UrlbarPrefs.set("quicksuggest.allowPositionInSuggestions", true); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: [ + { + ...REMOTE_SETTINGS_RESULTS[0], + position: 9, + }, + ], + }, + ]); + + let context = createContext(SPONSORED_SEARCH_STRING, { + isPrivate: false, + }); + + // Add some visits to fill up the view. + let maxResultCount = UrlbarPrefs.get("maxRichResults"); + let visitResults = []; + for (let i = 0; i < maxResultCount; i++) { + let url = `http://example.com/${SPONSORED_SEARCH_STRING}-${i}`; + await PlacesTestUtils.addVisits(url); + visitResults.unshift( + makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + }) + ); + } + + // Do a search. + await check_results({ + context, + matches: [ + // search heuristic + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.getIconURL(), + heuristic: true, + }), + // best match whose backing suggestion has a `position` + expectedSponsoredPriorityResult(), + // visits + ...visitResults.slice(0, maxResultCount - 2), + ], + }); + + await cleanupPlaces(); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ]); + + UrlbarPrefs.clear("quicksuggest.allowPositionInSuggestions"); + Services.prefs.clearUserPref("browser.search.suggest.enabled"); + Services.prefs.clearUserPref("browser.urlbar.quicksuggest.sponsoredPriority"); +}); + +// The `Amp` and `Wikipedia` Rust providers should be passed to the Rust +// component when querying depending on whether sponsored and non-sponsored +// suggestions are enabled. +add_task(async function rustProviders() { + await doRustProvidersTests({ + searchString: SPONSORED_AND_NONSPONSORED_SEARCH_STRING, + tests: [ + { + prefs: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + expectedUrls: [ + "http://example.com/amp", + "http://example.com/wikipedia", + ], + }, + { + prefs: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + expectedUrls: ["http://example.com/wikipedia"], + }, + { + prefs: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + }, + expectedUrls: ["http://example.com/amp"], + }, + { + prefs: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + expectedUrls: [], + }, + ], + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js new file mode 100644 index 0000000000..c17f3f1655 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js @@ -0,0 +1,558 @@ +/* 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 addon quick suggest results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", +}); + +// TODO: Firefox no longer uses `rating` and `number_of_ratings` but they are +// still present in Merino and RS suggestions, so they are included here for +// greater accuracy. We should remove them from Merino, RS, and tests. +const MERINO_SUGGESTIONS = [ + { + provider: "amo", + icon: "icon", + url: "https://example.com/merino-addon", + title: "title", + description: "description", + is_top_pick: true, + custom_details: { + amo: { + rating: "5", + number_of_ratings: "1234567", + guid: "test@addon", + }, + }, + }, +]; + +const REMOTE_SETTINGS_RESULTS = [ + { + type: "amo-suggestions", + attachment: [ + { + url: "https://example.com/first-addon", + guid: "first@addon", + icon: "https://example.com/first-addon.svg", + title: "First Addon", + rating: "4.7", + keywords: ["first", "1st", "two words", "a b c"], + description: "Description for the First Addon", + number_of_ratings: 1256, + score: 0.25, + }, + { + url: "https://example.com/second-addon", + guid: "second@addon", + icon: "https://example.com/second-addon.svg", + title: "Second Addon", + rating: "1.7", + keywords: ["second", "2nd"], + description: "Description for the Second Addon", + number_of_ratings: 256, + score: 0.25, + }, + { + url: "https://example.com/third-addon", + guid: "third@addon", + icon: "https://example.com/third-addon.svg", + title: "Third Addon", + rating: "3.7", + keywords: ["third", "3rd"], + description: "Description for the Third Addon", + number_of_ratings: 3, + score: 0.25, + }, + { + url: "https://example.com/fourth-addon?utm_medium=aaa&utm_source=bbb", + guid: "fourth@addon", + icon: "https://example.com/fourth-addon.svg", + title: "Fourth Addon", + rating: "4.7", + keywords: ["fourth", "4th"], + description: "Description for the Fourth Addon", + number_of_ratings: 4, + score: 0.25, + }, + ], + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_RESULTS, + merinoSuggestions: MERINO_SUGGESTIONS, + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); +}); + +add_task(async function telemetryType() { + Assert.equal( + QuickSuggest.getFeature("AddonSuggestions").getSuggestionTelemetryType({}), + "amo", + "Telemetry type should be 'amo'" + ); +}); + +// When quick suggest prefs are disabled, addon suggestions should be disabled. +add_tasks_with_rust(async function quickSuggestPrefsDisabled() { + let prefs = ["quicksuggest.enabled", "suggest.quicksuggest.nonsponsored"]; + for (let pref of prefs) { + // Before disabling the pref, first make sure the suggestion is added. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// When addon suggestions specific preference is disabled, addon suggestions +// should not be added. +add_tasks_with_rust(async function addonSuggestionsSpecificPrefDisabled() { + const prefs = ["suggest.addons", "addons.featureGate"]; + for (const pref of prefs) { + // First make sure the suggestion is added. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.clear(pref); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// Check wheather the addon suggestions will be shown by the setup of Nimbus +// variable. +add_tasks_with_rust(async function nimbus() { + // Disable the fature gate. + UrlbarPrefs.set("addons.featureGate", false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + addonsFeatureGate: true, + }); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("addons.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({ + addonsFeatureGate: false, + }); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + UrlbarPrefs.clear("addons.featureGate"); + await QuickSuggestTestUtils.forceSync(); +}); + +add_tasks_with_rust(async function hideIfAlreadyInstalled() { + // Show suggestion. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + }), + ], + }); + + // Install an addon for the suggestion. + const xpi = ExtensionTestCommon.generateXPI({ + manifest: { + browser_specific_settings: { + gecko: { id: "test@addon" }, + }, + }, + }); + const addon = await AddonManager.installTemporaryAddon(xpi); + + // Show suggestion for the addon installed. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + await addon.uninstall(); + xpi.remove(false); +}); + +add_tasks_with_rust(async function remoteSettings() { + const testCases = [ + { + input: "f", + expected: null, + }, + { + input: "fi", + expected: null, + }, + { + input: "fir", + expected: null, + }, + { + input: "firs", + expected: null, + }, + { + input: "first", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "1st", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "t", + expected: null, + }, + { + input: "tw", + expected: null, + }, + { + input: "two", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two w", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two wo", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two wor", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two word", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "two words", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a b", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a b ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "a b c", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + }, + { + input: "second", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + }), + }, + { + input: "2nd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + }), + }, + { + input: "third", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + }), + }, + { + input: "3rd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + }), + }, + { + input: "fourth", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[3], + source: "remote-settings", + setUtmParams: false, + }), + }, + { + input: "FoUrTh", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[3], + source: "remote-settings", + setUtmParams: false, + }), + }, + ]; + + // Disable Merino so we trigger only remote settings suggestions. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + for (let { input, expected } of testCases) { + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: expected ? [expected] : [], + }); + } + + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); +}); + +add_task(async function merinoIsTopPick() { + const suggestion = JSON.parse(JSON.stringify(MERINO_SUGGESTIONS[0])); + + // is_top_pick is specified as false. + suggestion.is_top_pick = false; + MerinoTestUtils.server.response.body.suggestions = [suggestion]; + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion, + source: "merino", + }), + ], + }); + + // is_top_pick is undefined. + delete suggestion.is_top_pick; + MerinoTestUtils.server.response.body.suggestions = [suggestion]; + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion, + source: "merino", + }), + ], + }); +}); + +// Tests the "show less frequently" behavior. +add_tasks_with_rust(async function showLessFrequently() { + await doShowLessFrequentlyTests({ + feature: QuickSuggest.getFeature("AddonSuggestions"), + showLessFrequentlyCountPref: "addons.showLessFrequentlyCount", + nimbusCapVariable: "addonsShowLessFrequentlyCap", + expectedResult: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + }), + keyword: "two words", + }); +}); + +// The `Amo` Rust provider should be passed to the Rust component when querying +// depending on whether addon suggestions are enabled. +add_task(async function rustProviders() { + await doRustProvidersTests({ + searchString: "first", + tests: [ + { + prefs: { + "suggest.addons": true, + }, + expectedUrls: ["https://example.com/first-addon"], + }, + { + prefs: { + "suggest.addons": false, + }, + expectedUrls: [], + }, + ], + }); + + UrlbarPrefs.clear("suggest.addons"); + await QuickSuggestTestUtils.forceSync(); +}); + +function makeExpectedResult({ suggestion, source, setUtmParams = true }) { + if ( + source == "remote-settings" && + UrlbarPrefs.get("quicksuggest.rustEnabled") + ) { + source = "rust"; + } + + let provider; + switch (source) { + case "remote-settings": + provider = "AddonSuggestions"; + break; + case "rust": + provider = "Amo"; + break; + case "merino": + provider = "amo"; + break; + } + + return makeAmoResult({ + source, + provider, + setUtmParams, + title: suggestion.title, + description: suggestion.description, + url: suggestion.url, + originalUrl: suggestion.url, + icon: suggestion.icon, + }); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js new file mode 100644 index 0000000000..a9f339c324 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js @@ -0,0 +1,103 @@ +/* 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 dynamic Wikipedia quick suggest results. + +"use strict"; + +const MERINO_SUGGESTIONS = [ + { + title: "title", + url: "url", + is_sponsored: false, + score: 0.23, + description: "description", + icon: "icon", + full_keyword: "full_keyword", + advertiser: "dynamic-wikipedia", + block_id: 0, + impression_url: "impression_url", + click_url: "click_url", + provider: "wikipedia", + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); +}); + +// When non-sponsored suggestions are disabled, dynamic Wikipedia suggestions +// should be disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Dynamic Wikipedia suggestions are + // non-sponsored, so doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); +}); + +add_task(async function mixedCaseQuery() { + await check_results({ + context: createContext("TeSt", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); +}); + +function makeExpectedResult() { + return { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + suggestedIndex: -1, + payload: { + telemetryType: "wikipedia", + title: "title", + url: "url", + displayUrl: "url", + isSponsored: false, + icon: "icon", + qsSuggestion: "full_keyword", + source: "merino", + provider: "wikipedia", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js new file mode 100644 index 0000000000..1c00cb5320 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js @@ -0,0 +1,3907 @@ +/* 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 impression frequency capping for quick suggest results. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://example.com/sponsored", + title: "Sponsored suggestion", + keywords: ["sponsored"], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }, + { + id: 2, + url: "http://example.com/nonsponsored", + title: "Non-sponsored suggestion", + keywords: ["nonsponsored"], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "5 - Education", + }, +]; + +const EXPECTED_SPONSORED_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/sponsored", + originalUrl: "http://example.com/sponsored", + displayUrl: "http://example.com/sponsored", + title: "Sponsored suggestion", + qsSuggestion: "sponsored", + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "22 - Shopping", + descriptionL10n: { id: "urlbar-result-action-sponsored" }, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + source: "remote-settings", + provider: "AdmWikipedia", + }, +}; + +const EXPECTED_NONSPONSORED_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_nonsponsored", + url: "http://example.com/nonsponsored", + originalUrl: "http://example.com/nonsponsored", + displayUrl: "http://example.com/nonsponsored", + title: "Non-sponsored suggestion", + qsSuggestion: "nonsponsored", + icon: null, + isSponsored: false, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 2, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "5 - Education", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + source: "remote-settings", + provider: "AdmWikipedia", + }, +}; + +let gSandbox; +let gDateNowStub; +let gStartupDateMsStub; + +add_setup(async () => { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + prefs: [ + ["quicksuggest.impressionCaps.sponsoredEnabled", true], + ["quicksuggest.impressionCaps.nonSponsoredEnabled", true], + ["suggest.quicksuggest.nonsponsored", true], + ["suggest.quicksuggest.sponsored", true], + ], + }); + + // Set up a sinon stub for the `Date.now()` implementation inside of + // UrlbarProviderQuickSuggest. This lets us test searches performed at + // specific times. See `doTimedCallbacks()` for more info. + gSandbox = sinon.createSandbox(); + gDateNowStub = gSandbox.stub( + Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date, + "now" + ); + + // Set up a sinon stub for `UrlbarProviderQuickSuggest._getStartupDateMs()` to + // let the test override the startup date. + gStartupDateMsStub = gSandbox.stub( + QuickSuggest.impressionCaps, + "_getStartupDateMs" + ); + gStartupDateMsStub.returns(0); +}); + +// Tests a single interval. +add_task(async function oneInterval() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 1: { + results: [[]], + }, + 2: { + results: [[]], + }, + 3: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 4: { + results: [[]], + }, + 5: { + results: [[]], + }, + }); + }, + }); +}); + +// Tests multiple intervals. +add_task(async function multipleIntervals() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + { interval_s: 10, max_count: 5 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: 1 new impression; 5 impressions total + 6: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "6000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 10, max_count: 5 + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 5 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "6000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 8s: no new impressions; 5 impressions total + 8: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "8000", + intervalSeconds: "1", + maxCount: "1", + startDate: "7000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 9s: no new impressions; 5 impressions total + 9: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "9000", + intervalSeconds: "1", + maxCount: "1", + startDate: "8000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 10s: 1 new impression; 6 impressions total + 10: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "9000", + impressionDate: "6000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "5", + maxCount: "3", + startDate: "5000", + impressionDate: "6000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 10, max_count: 5 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "10000", + impressionDate: "10000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 11s: 1 new impression; 7 impressions total + 11: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "11000", + intervalSeconds: "1", + maxCount: "1", + startDate: "10000", + impressionDate: "10000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "11000", + intervalSeconds: "1", + maxCount: "1", + startDate: "11000", + impressionDate: "11000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 12s: 1 new impression; 8 impressions total + 12: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "12000", + intervalSeconds: "1", + maxCount: "1", + startDate: "11000", + impressionDate: "11000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "12000", + intervalSeconds: "1", + maxCount: "1", + startDate: "12000", + impressionDate: "12000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "12000", + intervalSeconds: "5", + maxCount: "3", + startDate: "10000", + impressionDate: "12000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 13s: no new impressions; 8 impressions total + 13: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "13000", + intervalSeconds: "1", + maxCount: "1", + startDate: "12000", + impressionDate: "12000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 14s: no new impressions; 8 impressions total + 14: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "14000", + intervalSeconds: "1", + maxCount: "1", + startDate: "13000", + impressionDate: "12000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 15s: 1 new impression; 9 impressions total + 15: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "15000", + intervalSeconds: "1", + maxCount: "1", + startDate: "14000", + impressionDate: "12000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "15000", + intervalSeconds: "5", + maxCount: "3", + startDate: "10000", + impressionDate: "12000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "15000", + intervalSeconds: "1", + maxCount: "1", + startDate: "15000", + impressionDate: "15000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 16s: 1 new impression; 10 impressions total + 16: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "16000", + intervalSeconds: "1", + maxCount: "1", + startDate: "15000", + impressionDate: "15000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "16000", + intervalSeconds: "1", + maxCount: "1", + startDate: "16000", + impressionDate: "16000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 10, max_count: 5 + { + object: "hit", + extra: { + eventDate: "16000", + intervalSeconds: "10", + maxCount: "5", + startDate: "10000", + impressionDate: "16000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 17s: no new impressions; 10 impressions total + 17: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "17000", + intervalSeconds: "1", + maxCount: "1", + startDate: "16000", + impressionDate: "16000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 18s: no new impressions; 10 impressions total + 18: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "18000", + intervalSeconds: "1", + maxCount: "1", + startDate: "17000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 19s: no new impressions; 10 impressions total + 19: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "19000", + intervalSeconds: "1", + maxCount: "1", + startDate: "18000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 20s: 1 new impression; 11 impressions total + 20: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "1", + maxCount: "1", + startDate: "19000", + impressionDate: "16000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "5", + maxCount: "3", + startDate: "15000", + impressionDate: "16000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 10, max_count: 5 + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "5", + startDate: "10000", + impressionDate: "16000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "20000", + intervalSeconds: "1", + maxCount: "1", + startDate: "20000", + impressionDate: "20000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Tests a lifetime cap. +add_task(async function lifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + 0: { + results: [ + [EXPECTED_SPONSORED_URLBAR_RESULT], + [EXPECTED_SPONSORED_URLBAR_RESULT], + [EXPECTED_SPONSORED_URLBAR_RESULT], + [], + ], + telemetry: { + events: [ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 1: { + results: [[]], + }, + }); + }, + }); +}); + +// Tests one interval and a lifetime cap together. +add_task(async function intervalAndLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Tests multiple intervals and a lifetime cap together. +add_task(async function multipleIntervalsAndLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 4, + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("sponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 4 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "Infinity", + maxCount: "4", + startDate: "0", + impressionDate: "5000", + count: "4", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: no new impressions; 4 impressions total + 6: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 4 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "5000", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Smoke test for non-sponsored caps. Most tasks use sponsored results and caps, +// but sponsored and non-sponsored should behave the same since they use the +// same code paths. +add_task(async function nonsponsored() { + await doTest({ + config: { + impression_caps: { + nonsponsored: { + lifetime: 4, + custom: [ + { interval_s: 1, max_count: 1 }, + { interval_s: 5, max_count: 3 }, + ], + }, + }, + }, + callback: async () => { + await doTimedSearches("nonsponsored", { + // 0s: 1 new impression; 1 impression total + 0: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 1s: 1 new impression; 2 impressions total + 1: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 2s: 1 new impression; 3 impressions total + 2: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 5, max_count: 3 + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 3s: no new impressions; 3 impressions total + 3: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "1", + maxCount: "1", + startDate: "2000", + impressionDate: "2000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 4s: no new impressions; 3 impressions total + 4: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "4000", + intervalSeconds: "1", + maxCount: "1", + startDate: "3000", + impressionDate: "2000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 5s: 1 new impression; 4 impressions total + 5: { + results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "2000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + // reset: interval_s: 5, max_count: 3 + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "2000", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: 1, max_count: 1 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + // hit: interval_s: Infinity, max_count: 4 + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "Infinity", + maxCount: "4", + startDate: "0", + impressionDate: "5000", + count: "4", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 6s: no new impressions; 4 impressions total + 6: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "5000", + count: "1", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + // 7s: no new impressions; 4 impressions total + 7: { + results: [[]], + telemetry: { + events: [ + // reset: interval_s: 1, max_count: 1 + { + object: "reset", + extra: { + eventDate: "7000", + intervalSeconds: "1", + maxCount: "1", + startDate: "6000", + impressionDate: "5000", + count: "0", + type: "nonsponsored", + eventCount: "1", + }, + }, + ], + }, + }, + }); + }, + }); +}); + +// Smoke test for sponsored and non-sponsored caps together. Most tasks use only +// sponsored results and caps, but sponsored and non-sponsored should behave the +// same since they use the same code paths. +add_task(async function sponsoredAndNonsponsored() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 2, + }, + nonsponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + // 1st searches + await checkSearch({ + name: "sponsored 1", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored 1", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([]); + + // 2nd searches + await checkSearch({ + name: "sponsored 2", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored 2", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + + // 3rd searches + await checkSearch({ + name: "sponsored 3", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored 3", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ]); + + // 4th searches + await checkSearch({ + name: "sponsored 4", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored 4", + searchString: "nonsponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); +}); + +// Tests with an empty config to make sure results are not capped. +add_task(async function emptyConfig() { + await doTest({ + config: {}, + callback: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([]); + }, + }); +}); + +// Tests with sponsored caps disabled. Non-sponsored should still be capped. +add_task(async function sponsoredCapsDisabled() { + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", false); + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 0, + }, + nonsponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "nonsponsored", + eventCount: "1", + }, + }, + ]); + + await checkSearch({ + name: "sponsored additional", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored additional", + searchString: "nonsponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", true); +}); + +// Tests with non-sponsored caps disabled. Sponsored should still be capped. +add_task(async function nonsponsoredCapsDisabled() { + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", false); + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + nonsponsored: { + lifetime: 0, + }, + }, + }, + callback: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "sponsored " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "nonsponsored " + i, + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + + await checkSearch({ + name: "sponsored additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkSearch({ + name: "nonsponsored additional", + searchString: "nonsponsored", + expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT], + }); + await checkTelemetryEvents([]); + }, + }); + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", true); +}); + +// Tests a config change: 1 interval -> same interval with lower cap, with the +// old cap already reached +add_task(async function configChange_sameIntervalLowerCap_1() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 3: async () => { + await checkSearch({ + name: "3s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> same interval with lower cap, with the +// old cap not reached +add_task(async function configChange_sameIntervalLowerCap_2() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 1 }], + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + }, + 3: async () => { + await checkSearch({ + name: "3s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "1", + startDate: "3000", + impressionDate: "3000", + count: "1", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> same interval with higher cap +add_task(async function configChange_sameIntervalHigherCap() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 5 }], + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "1s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "1s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "3", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 3: async () => { + for (let i = 0; i < 5; i++) { + await checkSearch({ + name: "3s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "3000", + intervalSeconds: "3", + maxCount: "5", + startDate: "3000", + impressionDate: "3000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> 2 new intervals with higher timeouts. +// Impression counts for the old interval should contribute to the new +// intervals. +add_task(async function configChange_1IntervalTo2NewIntervalsHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [ + { interval_s: 5, max_count: 3 }, + { interval_s: 10, max_count: 5 }, + ], + }, + }, + }); + }, + 3: async () => { + await checkSearch({ + name: "3s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 4: async () => { + await checkSearch({ + name: "4s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 5: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "5s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "5s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "5000", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "5000", + intervalSeconds: "10", + maxCount: "5", + startDate: "0", + impressionDate: "5000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 2 intervals -> 1 new interval with higher timeout. +// Impression counts for the old intervals should contribute to the new +// interval. +add_task(async function configChange_2IntervalsTo1NewIntervalHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [ + { interval_s: 2, max_count: 2 }, + { interval_s: 4, max_count: 4 }, + ], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "2", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 2: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "2s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "2000", + intervalSeconds: "2", + maxCount: "2", + startDate: "0", + impressionDate: "0", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "2", + maxCount: "2", + startDate: "2000", + impressionDate: "2000", + count: "2", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "2000", + intervalSeconds: "4", + maxCount: "4", + startDate: "0", + impressionDate: "2000", + count: "4", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 6, max_count: 5 }], + }, + }, + }); + }, + 4: async () => { + await checkSearch({ + name: "4s 0", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + await checkSearch({ + name: "4s 1", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "4000", + intervalSeconds: "6", + maxCount: "5", + startDate: "0", + impressionDate: "4000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + 5: async () => { + await checkSearch({ + name: "5s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + 6: async () => { + for (let i = 0; i < 5; i++) { + await checkSearch({ + name: "6s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "6s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "6000", + intervalSeconds: "6", + maxCount: "5", + startDate: "0", + impressionDate: "4000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + { + object: "hit", + extra: { + eventDate: "6000", + intervalSeconds: "6", + maxCount: "5", + startDate: "6000", + impressionDate: "6000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> 1 new interval with lower timeout. +// Impression counts for the old interval should not contribute to the new +// interval since the new interval has a lower timeout. +add_task(async function configChange_1IntervalTo1NewIntervalLower() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 5, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "5", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "3s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "3s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "1000", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: 1 interval -> lifetime. +// Impression counts for the old interval should contribute to the new lifetime +// cap. +add_task(async function configChange_1IntervalToLifetime() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "3", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }); + }, + 3: async () => { + await checkSearch({ + name: "3s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + }, + }); +}); + +// Tests a config change: lifetime cap -> higher lifetime cap +add_task(async function configChange_lifetimeCapHigher() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 5, + }, + }, + }); + }, + 1: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: "1s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "1s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "1000", + intervalSeconds: "Infinity", + maxCount: "5", + startDate: "0", + impressionDate: "1000", + count: "5", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); +}); + +// Tests a config change: lifetime cap -> lower lifetime cap +add_task(async function configChange_lifetimeCapLower() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 3, + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + 0: async () => { + for (let i = 0; i < 3; i++) { + await checkSearch({ + name: "0s " + i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + await checkSearch({ + name: "0s additional", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([ + { + object: "hit", + extra: { + eventDate: "0", + intervalSeconds: "Infinity", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "3", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + await QuickSuggestTestUtils.setConfig({ + impression_caps: { + sponsored: { + lifetime: 1, + }, + }, + }); + }, + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [], + }); + await checkTelemetryEvents([]); + }, + }); + }, + }); +}); + +// Makes sure stats are serialized to and from the pref correctly. +add_task(async function prefSync() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 5, + custom: [ + { interval_s: 3, max_count: 2 }, + { interval_s: 5, max_count: 4 }, + ], + }, + }, + }, + callback: async () => { + for (let i = 0; i < 2; i++) { + await checkSearch({ + name: i, + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + } + + let json = UrlbarPrefs.get("quicksuggest.impressionCaps.stats"); + Assert.ok(json, "JSON is non-empty"); + Assert.deepEqual( + JSON.parse(json), + { + sponsored: [ + { + intervalSeconds: 3, + count: 2, + maxCount: 2, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: 5, + count: 2, + maxCount: 4, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: null, + count: 2, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }, + "JSON is correct" + ); + + QuickSuggest.impressionCaps._test_reloadStats(); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + { + sponsored: [ + { + intervalSeconds: 3, + count: 2, + maxCount: 2, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: 5, + count: 2, + maxCount: 4, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 2, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }, + "Impression stats were properly restored from the pref" + ); + }, + }); +}); + +// Tests direct changes to the stats pref. +add_task(async function prefDirectlyChanged() { + await doTest({ + config: { + impression_caps: { + sponsored: { + lifetime: 5, + custom: [{ interval_s: 3, max_count: 3 }], + }, + }, + }, + callback: async () => { + let expectedStats = { + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 3, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }; + + UrlbarPrefs.set("quicksuggest.impressionCaps.stats", "bogus"); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for 'bogus'" + ); + + UrlbarPrefs.set("quicksuggest.impressionCaps.stats", JSON.stringify({})); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for {}" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ sponsored: "bogus" }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats for { sponsored: 'bogus' }" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 3, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: "bogus", + count: 0, + maxCount: 99, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 5, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats with intervalSeconds: 'bogus'" + ); + + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify({ + sponsored: [ + { + intervalSeconds: 3, + count: 0, + maxCount: 123, + startDateMs: 0, + impressionDateMs: 0, + }, + { + intervalSeconds: Infinity, + count: 0, + maxCount: 456, + startDateMs: 0, + impressionDateMs: 0, + }, + ], + }) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + expectedStats, + "Expected stats with `maxCount` values different from caps" + ); + + let stats = { + sponsored: [ + { + intervalSeconds: 3, + count: 1, + maxCount: 3, + startDateMs: 99, + impressionDateMs: 99, + }, + { + intervalSeconds: Infinity, + count: 7, + maxCount: 5, + startDateMs: 1337, + impressionDateMs: 1337, + }, + ], + }; + UrlbarPrefs.set( + "quicksuggest.impressionCaps.stats", + JSON.stringify(stats) + ); + Assert.deepEqual( + QuickSuggest.impressionCaps._test_stats, + stats, + "Expected stats with valid JSON" + ); + }, + }); +}); + +// Tests multiple interval periods where the cap is not hit. Telemetry should be +// recorded for these periods. +add_task(async function intervalsElapsedButCapNotHit() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 3 }], + }, + }, + }, + callback: async () => { + await doTimedCallbacks({ + // 1s + 1: async () => { + await checkSearch({ + name: "1s", + searchString: "sponsored", + expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT], + }); + }, + // 10s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + let expectedEvents = [ + // 1s: reset with count = 0 + { + object: "reset", + extra: { + eventDate: "1000", + intervalSeconds: "1", + maxCount: "3", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + // 2-10s: reset with count = 1, eventCount = 9 + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "3", + startDate: "1000", + impressionDate: "1000", + count: "1", + type: "sponsored", + eventCount: "9", + }, + }, + ]; + await checkTelemetryEvents(expectedEvents); + }, + }); + }, + }); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 4.5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 6 batched resets for periods starting at 4s +add_task(async function restart_1() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(4500); + await doTimedCallbacks({ + // 10s: 6 batched resets for periods starting at 4s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "4000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "6", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 5 batched resets for periods starting at 5s +add_task(async function restart_2() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 10s: 5 batched resets for periods starting at 5s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "5", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >----|----|----|----|----|----|----|----|----|----| +// 0s 1 2 3 4 5 6 7 8 9 10 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 1 +// 3. Startup at 5.5s +// 4. Reset triggered at 10s +// +// Expected: +// At 10s: 5 batched resets for periods starting at 5s +add_task(async function restart_3() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5500); + await doTimedCallbacks({ + // 10s: 5 batched resets for periods starting at 5s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "5000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "5", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S RR RR +// >---------|---------| +// 0s 10 20 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 5s +// 4. Resets triggered at 9s, 10s, 19s, 20s +// +// Expected: +// At 10s: 1 reset for period starting at 0s +// At 20s: 1 reset for period starting at 10s +add_task(async function restart_4() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 9s: no resets + 9: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 10s: 1 reset for period starting at 0s + 10: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "10", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + // 19s: no resets + 19: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 20s: 1 reset for period starting at 10s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >---------|---------| +// 0s 10 20 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 5s +// 4. Reset triggered at 20s +// +// Expected: +// At 20s: 2 batched resets for periods starting at 0s +add_task(async function restart_5() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(5000); + await doTimedCallbacks({ + // 20s: 2 batches resets for periods starting at 0s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "2", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S RR RR +// >---------|---------|---------| +// 0s 10 20 30 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 15s +// 4. Resets triggered at 19s, 20s, 29s, 30s +// +// Expected: +// At 20s: 1 reset for period starting at 10s +// At 30s: 1 reset for period starting at 20s +add_task(async function restart_6() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(15000); + await doTimedCallbacks({ + // 19s: no resets + 19: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 20s: 1 reset for period starting at 10s + 20: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "20000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + // 29s: no resets + 29: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([]); + }, + // 30s: 1 reset for period starting at 20s + 30: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "30000", + intervalSeconds: "10", + maxCount: "1", + startDate: "20000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "1", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Simulates reset events across a restart with the following: +// +// S S R +// >---------|---------|---------| +// 0s 10 20 30 +// +// 1. Startup at 0s +// 2. Caps and stats initialized with interval_s: 10 +// 3. Startup at 15s +// 4. Reset triggered at 30s +// +// Expected: +// At 30s: 2 batched resets for periods starting at 10s +add_task(async function restart_7() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 10, max_count: 1 }], + }, + }, + }, + callback: async () => { + gStartupDateMsStub.returns(15000); + await doTimedCallbacks({ + // 30s: 2 batched resets for periods starting at 10s + 30: async () => { + QuickSuggest.impressionCaps._test_resetElapsedCounters(); + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "30000", + intervalSeconds: "10", + maxCount: "1", + startDate: "10000", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "2", + }, + }, + ]); + }, + }); + }, + }); + gStartupDateMsStub.returns(0); +}); + +// Tests reset telemetry recorded on shutdown. +add_task(async function shutdown() { + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 1, max_count: 1 }], + }, + }, + }, + callback: async () => { + // Make `Date.now()` return 10s. Since the cap's `interval_s` is 1s and + // before this `Date.now()` returned 0s, 10 reset events should be + // recorded on shutdown. + gDateNowStub.returns(10000); + + // Simulate shutdown. + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileChangeTeardown._trigger(); + + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: "10000", + intervalSeconds: "1", + maxCount: "1", + startDate: "0", + impressionDate: "0", + count: "0", + type: "sponsored", + eventCount: "10", + }, + }, + ]); + + gDateNowStub.returns(0); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + }, + }); +}); + +// Tests the reset interval in realtime. +add_task(async function resetInterval() { + // Remove the test stubs so we can test in realtime. + gDateNowStub.restore(); + gStartupDateMsStub.restore(); + + await doTest({ + config: { + impression_caps: { + sponsored: { + custom: [{ interval_s: 0.1, max_count: 1 }], + }, + }, + }, + callback: async () => { + // Restart the reset interval now with a 1s period. Since the cap's + // `interval_s` is 0.1s, at least 10 reset events should be recorded the + // first time the reset interval fires. The exact number depends on timing + // and the machine running the test: how many 0.1s intervals elapse + // between when the config is set to when the reset interval fires. For + // that reason, we allow some leeway when checking `eventCount` below to + // avoid intermittent failures. + QuickSuggest.impressionCaps._test_setCountersResetInterval(1000); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1100)); + + // Restore the reset interval to its default. + QuickSuggest.impressionCaps._test_setCountersResetInterval(); + + await checkTelemetryEvents([ + { + object: "reset", + extra: { + eventDate: /^[0-9]+$/, + intervalSeconds: "0.1", + maxCount: "1", + startDate: /^[0-9]+$/, + impressionDate: "0", + count: "0", + type: "sponsored", + // See comment above on allowing leeway for `eventCount`. + eventCount: str => { + info(`Checking 'eventCount': ${str}`); + let count = parseInt(str); + return 10 <= count && count < 20; + }, + }, + }, + ]); + }, + }); + + // Recreate the test stubs. + gDateNowStub = gSandbox.stub( + Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date, + "now" + ); + gStartupDateMsStub = gSandbox.stub( + QuickSuggest.impressionCaps, + "_getStartupDateMs" + ); + gStartupDateMsStub.returns(0); +}); + +/** + * Main test helper. Sets up state, calls your callback, and resets state. + * + * @param {object} options + * Options object. + * @param {object} options.config + * The quick suggest config to use during the test. + * @param {Function} options.callback + * The callback that will be run with the {@link config} + */ +async function doTest({ config, callback }) { + Services.telemetry.clearEvents(); + + // Make `Date.now()` return 0 to start with. It's necessary to do this before + // calling `withConfig()` because when a new config is set, the provider + // validates its impression stats, whose `startDateMs` values depend on + // `Date.now()`. + gDateNowStub.returns(0); + + info(`Clearing stats and setting config`); + UrlbarPrefs.clear("quicksuggest.impressionCaps.stats"); + QuickSuggest.impressionCaps._test_reloadStats(); + await QuickSuggestTestUtils.withConfig({ config, callback }); +} + +/** + * Does a series of timed searches and checks their results and telemetry. This + * function relies on `doTimedCallbacks()`, so it may be helpful to look at it + * too. + * + * @param {string} searchString + * The query that should be timed + * @param {object} expectedBySecond + * An object that maps from seconds to objects that describe the searches to + * perform, their expected results, and the expected telemetry. For a given + * entry `S -> E` in this object, searches are performed S seconds after this + * function is called. `E` is an object that looks like this: + * + * { results, telemetry } + * + * {array} results + * An array of arrays. A search is performed for each sub-array in + * `results`, and the contents of the sub-array are the expected results + * for that search. + * {object} telemetry + * An object like this: { events } + * {array} events + * An array of expected telemetry events after all searches are done. + * Telemetry events are cleared after checking these. If not present, + * then it will be asserted that no events were recorded. + * + * Example: + * + * { + * 0: { + * results: [[R1], []], + * telemetry: { + * events: [ + * someExpectedEvent, + * ], + * }, + * } + * 1: { + * results: [[]], + * }, + * } + * + * 0 seconds after `doTimedSearches()` is called, two searches are + * performed. The first one is expected to return a single result R1, and + * the second search is expected to return no results. After the searches + * are done, one telemetry event is expected to be recorded. + * + * 1 second after `doTimedSearches()` is called, one search is performed. + * It's expected to return no results, and no telemetry is expected to be + * recorded. + */ +async function doTimedSearches(searchString, expectedBySecond) { + await doTimedCallbacks( + Object.entries(expectedBySecond).reduce( + (memo, [second, { results, telemetry }]) => { + memo[second] = async () => { + for (let i = 0; i < results.length; i++) { + let expectedResults = results[i]; + await checkSearch({ + searchString, + expectedResults, + name: `${second}s search ${i + 1} of ${results.length}`, + }); + } + let { events } = telemetry || {}; + await checkTelemetryEvents(events || []); + }; + return memo; + }, + {} + ) + ); +} + +/** + * Takes a series a callbacks and times at which they should be called, and + * calls them accordingly. This function is specifically designed for + * UrlbarProviderQuickSuggest and its impression capping implementation because + * it works by stubbing `Date.now()` within UrlbarProviderQuickSuggest. The + * callbacks are not actually called at the given times but instead `Date.now()` + * is stubbed so that UrlbarProviderQuickSuggest will think they are being + * called at the given times. + * + * A more general implementation of this helper function that isn't tailored to + * UrlbarProviderQuickSuggest is commented out below, and unfortunately it + * doesn't work properly on macOS. + * + * @param {object} callbacksBySecond + * An object that maps from seconds to callback functions. For a given entry + * `S -> F` in this object, the callback F is called S seconds after + * `doTimedCallbacks()` is called. + */ +async function doTimedCallbacks(callbacksBySecond) { + let entries = Object.entries(callbacksBySecond).sort(([t1], [t2]) => t1 - t2); + for (let [timeoutSeconds, callback] of entries) { + gDateNowStub.returns(1000 * timeoutSeconds); + await callback(); + } +} + +/* +// This is the original implementation of `doTimedCallbacks()`, left here for +// reference or in case the macOS problem described below is fixed. Instead of +// stubbing `Date.now()` within UrlbarProviderQuickSuggest, it starts parallel +// timers so that the callbacks are actually called at appropriate times. This +// version of `doTimedCallbacks()` is therefore more generally useful, but it +// has the drawback that your test has to run in real time. e.g., if one of your +// callbacks needs to run 10s from now, the test must actually wait 10s. +// +// Unfortunately macOS seems to have some kind of limit of ~33 total 1-second +// timers during any xpcshell test -- not 33 simultaneous timers but 33 total +// timers. After that, timers fire randomly and with huge timeout periods that +// are often exactly 10s greater than the specified period, as if some 10s +// timeout internal to macOS is being hit. This problem does not seem to happen +// when running the full browser, only during xpcshell tests. In fact the +// problem can be reproduced in an xpcshell test that simply creates an interval +// timer whose period is 1s (e.g., using `setInterval()` from Timer.sys.mjs). +// After ~33 ticks, the timer's period jumps to ~10s. +async function doTimedCallbacks(callbacksBySecond) { + await Promise.all( + Object.entries(callbacksBySecond).map( + ([timeoutSeconds, callback]) => new Promise( + resolve => setTimeout( + () => callback().then(resolve), + 1000 * parseInt(timeoutSeconds) + ) + ) + ) + ); +} +*/ + +/** + * Does a search, triggers an engagement, and checks the results. + * + * @param {object} options + * Options object. + * @param {string} options.name + * This value is the name of the search and will be logged in messages to make + * debugging easier. + * @param {string} options.searchString + * The query that should be searched. + * @param {Array} options.expectedResults + * The results that are expected from the search. + */ +async function checkSearch({ name, searchString, expectedResults }) { + info(`Preparing search "${name}" with search string "${searchString}"`); + let context = createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + info(`Doing search: ${name}`); + await check_results({ + context, + matches: expectedResults, + }); + info(`Finished search: ${name}`); + + // Impression stats are updated only on engagement, so force one now. + // `selIndex` doesn't really matter but since we're not trying to simulate a + // click on the suggestion, pass in -1 to ensure we don't record a click. + if (UrlbarProviderQuickSuggest._resultFromLastQuery) { + UrlbarProviderQuickSuggest._resultFromLastQuery.isVisible = true; + } + const controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: true, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + controller.setView({ + get visibleResults() { + return context.results; + }, + controller: { + removeResult() {}, + }, + }); + UrlbarProviderQuickSuggest.onEngagement( + "engagement", + context, + { + selIndex: -1, + }, + controller + ); +} + +async function checkTelemetryEvents(expectedEvents) { + QuickSuggestTestUtils.assertEvents( + expectedEvents.map(event => ({ + ...event, + category: QuickSuggest.TELEMETRY_EVENT_CATEGORY, + method: "impression_cap", + })), + // Filter in only impression_cap events. + { method: "impression_cap" } + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js new file mode 100644 index 0000000000..e9bccba649 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js @@ -0,0 +1,190 @@ +/* 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 MDN quick suggest results. + +"use strict"; + +const REMOTE_SETTINGS_DATA = [ + { + type: "mdn-suggestions", + attachment: [ + { + url: "https://example.com/array-filter", + title: "Array.prototype.filter()", + description: + "The filter() method creates a shallow copy of a portion of a given array, filtered down to just the elements from the given array that pass the test implemented by the provided function.", + keywords: ["array filter"], + score: 0.24, + }, + { + url: "https://example.com/input", + title: ": The Input (Form Input) element", + description: + "The HTML element is used to create interactive controls for web-based forms in order to accept data from the user; a wide variety of types of input data and control widgets are available, depending on the device and user agent. The element is one of the most powerful and complex in all of HTML due to the sheer number of combinations of input types and attributes.", + keywords: ["input"], + score: 0.24, + }, + { + url: "https://example.com/grid", + title: "CSS Grid Layout", + description: + "CSS Grid Layout excels at dividing a page into major regions or defining the relationship in terms of size, position, and layer, between parts of a control built from HTML primitives.", + keywords: ["grid"], + score: 0.24, + }, + ], + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_DATA, + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["suggest.quicksuggest.sponsored", false], + ], + }); +}); + +add_tasks_with_rust(async function basic() { + for (const suggestion of REMOTE_SETTINGS_DATA[0].attachment) { + const fullKeyword = suggestion.keywords[0]; + const firstWord = fullKeyword.split(" ")[0]; + for (let i = 1; i < fullKeyword.length; i++) { + const keyword = fullKeyword.substring(0, i); + const shouldMatch = i >= firstWord.length; + const matches = shouldMatch ? [makeMdnResult(suggestion)] : []; + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches, + }); + } + + await check_results({ + context: createContext(fullKeyword + " ", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: + UrlbarPrefs.get("quickSuggestRustEnabled") && !fullKeyword.includes(" ") + ? [makeMdnResult(suggestion)] + : [], + }); + } +}); + +// Check wheather the MDN suggestions will be hidden by the pref. +add_tasks_with_rust(async function disableByLocalPref() { + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0]; + const keyword = suggestion.keywords[0]; + + const prefs = [ + "suggest.mdn", + "quicksuggest.enabled", + "suggest.quicksuggest.nonsponsored", + ]; + + for (const pref of prefs) { + // First make sure the suggestion is added. + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeMdnResult(suggestion)], + }); + + // Now disable them. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// Check wheather the MDN suggestions will be shown by the setup of Nimbus +// variable. +add_tasks_with_rust(async function nimbus() { + const defaultPrefs = Services.prefs.getDefaultBranch("browser.urlbar."); + + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0]; + const keyword = suggestion.keywords[0]; + + // Disable the fature gate. + defaultPrefs.setBoolPref("mdn.featureGate", false); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature( + { mdnFeatureGate: true }, + "urlbar", + "config" + ); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeMdnResult(suggestion)], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + defaultPrefs.setBoolPref("mdn.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature( + { mdnFeatureGate: false }, + "urlbar", + "config" + ); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + defaultPrefs.setBoolPref("mdn.featureGate", true); + await QuickSuggestTestUtils.forceSync(); +}); + +add_tasks_with_rust(async function mixedCaseQuery() { + const suggestion = REMOTE_SETTINGS_DATA[0].attachment[1]; + const keyword = "InPuT"; + + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeMdnResult(suggestion)], + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js new file mode 100644 index 0000000000..64f4991236 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js @@ -0,0 +1,574 @@ +/* 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 Merino integration with UrlbarProviderQuickSuggest. + +"use strict"; + +// relative to `browser.urlbar` +const PREF_DATA_COLLECTION_ENABLED = "quicksuggest.dataCollection.enabled"; + +const SEARCH_STRING = "frab"; + +const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RESULTS = [ + QuickSuggestTestUtils.ampRemoteSettings({ + keywords: [SEARCH_STRING], + }), +]; + +const EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT = makeAmpResult({ + keyword: SEARCH_STRING, +}); + +const EXPECTED_MERINO_URLBAR_RESULT = makeAmpResult({ + source: "merino", + provider: "adm", + requestId: "request_id", +}); + +// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino +// fetch, so it's easiest to create `gClient` lazily too. +ChromeUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_setup(async () => { + await MerinoTestUtils.server.start(); + + // Set up the remote settings client with the test data. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["suggest.quicksuggest.sponsored", true], + ], + }); + + Assert.equal( + typeof DEFAULT_SUGGESTION_SCORE, + "number", + "Sanity check: DEFAULT_SUGGESTION_SCORE is defined" + ); +}); + +// Tests with the Merino endpoint URL set to an empty string, which disables +// fetching from Merino. +add_task(async function merinoDisabled() { + let mockEndpointUrl = UrlbarPrefs.get("merino.endpointURL"); + UrlbarPrefs.set("merino.endpointURL", ""); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Clear the remote settings suggestions so that if Merino is actually queried + // -- which would be a bug -- we don't accidentally mask the Merino suggestion + // by also matching an RS suggestion with the same or higher score. + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: false, + client: UrlbarProviderQuickSuggest._test_merino, + }); + + UrlbarPrefs.set("merino.endpointURL", mockEndpointUrl); + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ]); +}); + +// Tests with Merino enabled but with data collection disabled. Results should +// not be fetched from Merino in that case. +add_task(async function dataCollectionDisabled() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, false); + + // Clear the remote settings suggestions so that if Merino is actually queried + // -- which would be a bug -- we don't accidentally mask the Merino suggestion + // by also matching an RS suggestion with the same or higher score. + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ]); +}); + +// When the Merino suggestion has a higher score than the remote settings +// suggestion, the Merino suggestion should be used. +add_task(async function higherScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + 2 * DEFAULT_SUGGESTION_SCORE; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_MERINO_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino suggestion has a lower score than the remote settings +// suggestion, the remote settings suggestion should be used. +add_task(async function lowerScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + DEFAULT_SUGGESTION_SCORE / 2; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino and remote settings suggestions have the same score, the +// remote settings suggestion should be used. +add_task(async function sameScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + DEFAULT_SUGGESTION_SCORE; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When the Merino suggestion does not include a score, the remote settings +// suggestion should be used. +add_task(async function noMerinoScore() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + Assert.equal( + typeof MerinoTestUtils.server.response.body.suggestions[0].score, + "number", + "Sanity check: First suggestion has a score" + ); + delete MerinoTestUtils.server.response.body.suggestions[0].score; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When remote settings doesn't return a suggestion but Merino does, the Merino +// suggestion should be used. +add_task(async function noSuggestion_remoteSettings() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext("this doesn't match remote settings", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_MERINO_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When Merino doesn't return a suggestion but remote settings does, the remote +// settings suggestion should be used. +add_task(async function noSuggestion_merino() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions = []; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When Merino returns multiple suggestions, the one with the largest score +// should be used. +add_task(async function multipleMerinoSuggestions() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions = [ + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 0 full_keyword", + title: "multipleMerinoSuggestions 0 title", + url: "multipleMerinoSuggestions 0 url", + icon: "multipleMerinoSuggestions 0 icon", + impression_url: "multipleMerinoSuggestions 0 impression_url", + click_url: "multipleMerinoSuggestions 0 click_url", + block_id: 0, + advertiser: "multipleMerinoSuggestions 0 advertiser", + iab_category: "22 - Shopping", + is_sponsored: true, + score: 0.1, + }, + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 1 full_keyword", + title: "multipleMerinoSuggestions 1 title", + url: "multipleMerinoSuggestions 1 url", + icon: "multipleMerinoSuggestions 1 icon", + impression_url: "multipleMerinoSuggestions 1 impression_url", + click_url: "multipleMerinoSuggestions 1 click_url", + block_id: 1, + advertiser: "multipleMerinoSuggestions 1 advertiser", + iab_category: "22 - Shopping", + is_sponsored: true, + score: 1, + }, + { + provider: "adm", + full_keyword: "multipleMerinoSuggestions 2 full_keyword", + title: "multipleMerinoSuggestions 2 title", + url: "multipleMerinoSuggestions 2 url", + icon: "multipleMerinoSuggestions 2 icon", + impression_url: "multipleMerinoSuggestions 2 impression_url", + click_url: "multipleMerinoSuggestions 2 click_url", + block_id: 2, + advertiser: "multipleMerinoSuggestions 2 advertiser", + iab_category: "22 - Shopping", + is_sponsored: true, + score: 0.2, + }, + ]; + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [ + makeAmpResult({ + keyword: "multipleMerinoSuggestions 1 full_keyword", + title: "multipleMerinoSuggestions 1 title", + url: "multipleMerinoSuggestions 1 url", + originalUrl: "multipleMerinoSuggestions 1 url", + icon: "multipleMerinoSuggestions 1 icon", + impressionUrl: "multipleMerinoSuggestions 1 impression_url", + clickUrl: "multipleMerinoSuggestions 1 click_url", + blockId: 1, + advertiser: "multipleMerinoSuggestions 1 advertiser", + requestId: "request_id", + source: "merino", + provider: "adm", + }), + ], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Timestamp templates in URLs should be replaced with real timestamps. +add_task(async function timestamps() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Set up the Merino response with template URLs. + let suggestion = MerinoTestUtils.server.response.body.suggestions[0]; + let { TIMESTAMP_TEMPLATE } = QuickSuggest; + + suggestion.url = `http://example.com/time-${TIMESTAMP_TEMPLATE}`; + suggestion.click_url = `http://example.com/time-${TIMESTAMP_TEMPLATE}-foo`; + + // Do a search. + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + let controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: context.isPrivate, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + await controller.startQuery(context); + + // Should be one quick suggest result. + Assert.equal(context.results.length, 1, "One result returned"); + let result = context.results[0]; + + QuickSuggestTestUtils.assertTimestampsReplaced(result, { + url: suggestion.click_url, + sponsoredClickUrl: suggestion.click_url, + }); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// When both suggestion types are disabled but data collection is enabled, we +// should still send requests to Merino, and the requests should include an +// empty `providers` to tell Merino not to fetch any suggestions. +add_task(async function suggestedDisabled_dataCollectionEnabled() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Check that the request is received and includes an empty `providers`. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: "test", + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + [MerinoTestUtils.SEARCH_PARAMS.PROVIDERS]: "", + }, + }, + ]); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: gClient, + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + await QuickSuggestTestUtils.forceSync(); + gClient.resetSession(); +}); + +// Test whether the blocking for Merino results works. +add_task(async function block() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Make sure the Merino suggestions have different URLs from the remote + // settings suggestion. + let { suggestions } = MerinoTestUtils.server.response.body; + for (let i = 0; i < suggestions.length; i++) { + let suggestion = suggestions[i]; + suggestion.url = "https://example.com/merino-url-" + i; + await QuickSuggest.blockedSuggestions.add(suggestion.url); + } + + const context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + await QuickSuggest.blockedSuggestions.clear(); + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); + +// Tests a Merino suggestion that is a top pick/best match. +add_task(async function bestMatch() { + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Set up a suggestion with `is_top_pick` and an unknown provider so that + // UrlbarProviderQuickSuggest will make a default result for it. + MerinoTestUtils.server.response.body.suggestions = [ + { + is_top_pick: true, + provider: "some_top_pick_provider", + full_keyword: "full_keyword", + title: "title", + url: "url", + icon: null, + score: 1, + }, + ]; + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [ + { + isBestMatch: true, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "some_top_pick_provider", + title: "title", + url: "url", + icon: null, + qsSuggestion: "full_keyword", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + displayUrl: "url", + source: "merino", + provider: "some_top_pick_provider", + }, + }, + ], + }); + + // This isn't necessary since `check_results()` checks `isBestMatch`, but + // check it here explicitly for good measure. + Assert.ok(context.results[0].isBestMatch, "Result is a best match"); + + MerinoTestUtils.server.reset(); + gClient.resetSession(); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js new file mode 100644 index 0000000000..61b1b9186f --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js @@ -0,0 +1,173 @@ +/* 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 Merino session integration with UrlbarProviderQuickSuggest. + +"use strict"; + +// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino +// fetch, so it's easiest to create `gClient` lazily too. +ChromeUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_setup(async () => { + await MerinoTestUtils.server.start(); + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["quicksuggest.dataCollection.enabled", true], + ], + }); +}); + +// In a single engagement, all requests should use the same session ID and the +// sequence number should be incremented. +add_task(async function singleEngagement() { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + await controller.startQuery( + createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i, + }, + }, + ]); + } + + // End the engagement to reset the session for the next test. + endEngagement({ controller }); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task completes each engagement +// successfully. +add_task(async function manyEngagements_engagement() { + await doManyEngagementsTest("engagement"); +}); + +// New engagements should not use the same session ID as previous engagements +// and the sequence number should be reset. This task abandons each engagement. +add_task(async function manyEngagements_abandonment() { + await doManyEngagementsTest("abandonment"); +}); + +async function doManyEngagementsTest(state) { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + let searchString = "search" + i; + let context = createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await controller.startQuery(context); + + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, + }, + }, + ]); + + endEngagement({ context, state, controller }); + } +} + +// When a search is canceled after the request is sent and before the Merino +// response is received, the sequence number should still be incremented. +add_task(async function canceledQueries() { + let controller = UrlbarTestUtils.newMockController(); + + for (let i = 0; i < 3; i++) { + // Send the first response after a delay to make sure the client will not + // receive it before we start the second fetch. + MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs"); + + // Start the first search. + let requestPromise = MerinoTestUtils.server.waitForNextRequest(); + let searchString1 = "search" + i; + controller.startQuery( + createContext(searchString1, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + // Wait until the first request is received before starting the second + // search. If we started the second search immediately, the first would be + // canceled before the provider is even called due to the urlbar's 50ms + // delay (see `browser.urlbar.delay`) so the sequence number would not be + // incremented for it. Here we want to test the case where the first search + // is canceled after the request is sent and the number is incremented. + await requestPromise; + delete MerinoTestUtils.server.response.delay; + + // Now do a second search that cancels the first. + let searchString2 = searchString1 + "again"; + await controller.startQuery( + createContext(searchString2, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }) + ); + + // The sequence number should have been incremented for each search. + MerinoTestUtils.server.checkAndClearRequests([ + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString1, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i, + }, + }, + { + params: { + [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString2, + [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1, + }, + }, + ]); + } + + // End the engagement to reset the session for the next test. + endEngagement({ controller }); +}); + +function endEngagement({ controller, context = null, state = "engagement" }) { + UrlbarProviderQuickSuggest.onEngagement( + state, + context || + createContext("endEngagement", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + { selIndex: -1 }, + controller + ); + + Assert.strictEqual( + gClient.sessionID, + null, + "sessionID is null after engagement" + ); + Assert.strictEqual( + gClient._test_sessionTimer, + null, + "sessionTimer is null after engagement" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js new file mode 100644 index 0000000000..851757b11b --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js @@ -0,0 +1,490 @@ +/* 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 quick suggest prefs migration from unversioned prefs to version 1. + +"use strict"; + +// Expected version 1 default-branch prefs +const DEFAULT_PREFS = { + history: { + "quicksuggest.enabled": false, + }, + offline: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + online: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, +}; + +// Migration will use these values to migrate only up to version 1 instead of +// the current version. +const TEST_OVERRIDES = { + migrationVersion: 1, + defaultPrefs: DEFAULT_PREFS, +}; + +add_setup(async () => { + await UrlbarTestUtils.initNimbusFeature(); +}); + +// The following tasks test OFFLINE TO OFFLINE + +// Migrating from: +// * Offline (suggestions on by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: remain off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE TO ONLINE + +// Migrating from: +// * Offline (suggestions on by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Offline (suggestions on by default) +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE TO OFFLINE + +// Migrating from: +// * Online (suggestions off by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on (since main pref had default value) +// * Sponsored suggestions: on (since main & sponsored prefs had default values) +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user left off +// * Sponsored suggestions: user turned on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on (see below) +// * Data collection: off +// +// It's unfortunate that sponsored suggestions are ultimately on since before +// the migration no suggestions were shown to the user. There's nothing we can +// do about it, aside from forcing off suggestions in more cases than we want. +// The reason is that at the time of migration we can't tell that the previous +// scenario was online -- or more precisely that it wasn't history. If we knew +// it wasn't history, then we'd know to turn sponsored off; if we knew it *was* +// history, then we'd know to turn sponsored -- and non-sponsored -- on, since +// the scenario at the time of migration is offline, where suggestions should be +// enabled by default. +// +// This is the reason we now record `quicksuggest.scenario` on the user branch +// and not the default branch as we previously did. +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: off (since scenario is offline) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: off (since scenario is offline) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// The following tasks test ONLINE TO ONLINE + +// Migrating from: +// * Online (suggestions off by default) +// * User did not override any defaults +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user left off +// * Sponsored suggestions: user turned on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain off +// * Data collection: ON (since user effectively opted in by turning on +// suggestions) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Online (suggestions off by default) +// * Main suggestions pref: user turned on +// * Sponsored suggestions: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: remain on +// * Sponsored suggestions: remain on +// * Data collection: ON (since user effectively opted in by turning on +// suggestions) +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js new file mode 100644 index 0000000000..991e8c66f9 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js @@ -0,0 +1,1355 @@ +/* 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 quick suggest prefs migration to version 2. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", +}); + +// Expected version 2 default-branch prefs +const DEFAULT_PREFS = { + history: { + "quicksuggest.enabled": false, + }, + offline: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": false, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + online: { + "quicksuggest.enabled": true, + "quicksuggest.dataCollection.enabled": false, + "quicksuggest.shouldShowOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, +}; + +// Migration will use these values to migrate only up to version 1 instead of +// the current version. +// Currently undefined because version 2 is the current migration version and we +// want migration to use its actual values, not overrides. When version 3 is +// added, set this to an object like the one in test_quicksuggest_migrate_v1.js. +const TEST_OVERRIDES = undefined; + +add_setup(async () => { + await UrlbarTestUtils.initNimbusFeature(); +}); + +// The following tasks test OFFLINE UNVERSIONED to OFFLINE + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user left on +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE UNVERSIONED to ONLINE + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user left on (but ignored since main was off) +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user left on +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Offline +// * Main suggestions pref: user turned off +// * Sponsored suggestions: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE UNVERSIONED to ONLINE when the user was NOT +// SHOWN THE MODAL (e.g., because they didn't restart) + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, did not +// turn on either type of suggestion, was not shown the modal (e.g., because +// they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, turned +// on main suggestions pref and left off sponsored suggestions, was not shown +// the modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on (since they opted in by checking the main checkbox +// while in online) +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + }, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, left +// off main suggestions pref and turned on sponsored suggestions, was not +// shown the modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": true, + }, + }, + }); + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: no +// * User enrolled in online where suggestions were disabled by default, turned +// on main suggestions pref and sponsored suggestions, was not shown the +// modal (e.g., because they didn't restart), and upgraded to v2 +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on (since they opted in by checking the main checkbox +// while in online) +add_task(async function () { + await withOnlineExperiment(async () => { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); + }); +}); + +// The following tasks test ONLINE UNVERSIONED to ONLINE when the user WAS SHOWN +// THE MODAL + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in and left off both the main suggestions pref and +// sponsored suggestions +// 2. User opted in but then later turned off both the main suggestions pref +// and sponsored suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on the main suggestions pref +// 2. User opted in but then later turned off sponsored suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on sponsored suggestions +// 2. User opted in but then later turned off the main suggestions pref +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Unversioned prefs +// * Online +// * Modal shown: yes +// * The following end up with same prefs and are covered by this task: +// 1. User did not opt in but then later turned on both the main suggestions +// pref and sponsored suggestions +// 2. User opted in and left on both the main suggestions pref and sponsored +// suggestions +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test OFFLINE VERSION 1 to OFFLINE + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Offline +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "offline", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.offline, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test OFFLINE VERSION 1 to ONLINE + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Offline +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user turned off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "offline", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user was NOT +// SHOWN THE MODAL (e.g., because they didn't restart) + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user turned on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user turned on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user turned on +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user turned on +// * Data collection: user turned on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN +// THE MODAL WHILE PREFS WERE UNVERSIONED + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: no +// * Non-sponsored suggestions: user turned on +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user turned off +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were unversioned +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user turned off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN +// THE MODAL WHILE PREFS WERE VERSION 1 + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were version 1 +// * User opted in: no +// * Non-sponsored suggestions: user left off +// * Sponsored suggestions: user left off +// * Data collection: user left off +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: off +// * Sponsored suggestions: off +// * Data collection: off +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "quicksuggest.onboardingDialogChoice": "not_now_link", + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": false, + "suggest.quicksuggest.sponsored": false, + "quicksuggest.dataCollection.enabled": false, + }, + }, + }); +}); + +// Migrating from: +// * Version 1 prefs +// * Online +// * Modal shown: yes, while prefs were version 1 +// * User opted in: yes +// * Non-sponsored suggestions: user left on +// * Sponsored suggestions: user left on +// * Data collection: user left on +// +// Scenario when migration occurs: +// * Online +// +// Expected: +// * Non-sponsored suggestions: on +// * Sponsored suggestions: on +// * Data collection: on +add_task(async function () { + await doMigrateTest({ + testOverrides: TEST_OVERRIDES, + initialUserBranch: { + "quicksuggest.migrationVersion": 1, + "quicksuggest.scenario": "online", + "quicksuggest.showedOnboardingDialog": true, + "quicksuggest.onboardingDialogChoice": "accept", + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + scenario: "online", + expectedPrefs: { + defaultBranch: DEFAULT_PREFS.online, + userBranch: { + "suggest.quicksuggest.nonsponsored": true, + "suggest.quicksuggest.sponsored": true, + "quicksuggest.dataCollection.enabled": true, + }, + }, + }); +}); + +async function withOnlineExperiment(callback) { + let { enrollmentPromise, doExperimentCleanup } = + ExperimentFakes.enrollmentHelper( + ExperimentFakes.recipe("firefox-suggest-offline-vs-online", { + active: true, + branches: [ + { + slug: "treatment", + features: [ + { + featureId: NimbusFeatures.urlbar.featureId, + value: { + enabled: true, + }, + }, + ], + }, + ], + }) + ); + await enrollmentPromise; + await callback(); + await doExperimentCleanup(); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js new file mode 100644 index 0000000000..8ac7b85ba2 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js @@ -0,0 +1,285 @@ +/* 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 non-unique keywords, i.e., keywords used by multiple suggestions. + +"use strict"; + +const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest; + +// For each of these objects, the test creates a quick suggest result (the kind +// stored in the remote settings data, not a urlbar result), the corresponding +// expected quick suggest suggestion, and the corresponding expected urlbar +// result. The test assumes results and suggestions are returned in the order +// listed here. +let SUGGESTIONS_DATA = [ + { + keywords: ["aaa"], + isSponsored: true, + score: DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["aaa", "bbb"], + isSponsored: false, + score: 2 * DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: true, + score: 4 * DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: false, + score: 3 * DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["ccc"], + isSponsored: true, + score: DEFAULT_SUGGESTION_SCORE, + }, +]; + +// Test cases. In this object, keywords map to subtest cases. For each keyword, +// the test calls `query(keyword)` and checks that the indexes (relative to +// `SUGGESTIONS_DATA`) of the returned quick suggest results are the ones in +// `expectedIndexes`. Then the test does a series of urlbar searches using the +// keyword as the search string, one search per object in `searches`. Sponsored +// and non-sponsored urlbar results are enabled as defined by `sponsored` and +// `nonsponsored`. `expectedIndex` is the expected index (relative to +// `SUGGESTIONS_DATA`) of the returned urlbar result. +let TESTS = { + aaa: { + // 0: sponsored + // 1: nonsponsored, score = 2x + expectedIndexes: [0, 1], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 1, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: 1, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 0, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, + bbb: { + // 1: nonsponsored, score = 2x + // 2: sponsored, score = 4x, + // 3: nonsponsored, score = 3x + expectedIndexes: [1, 2, 3], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 2, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: 3, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 2, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, + ccc: { + // 4: sponsored + expectedIndexes: [4], + searches: [ + { + sponsored: true, + nonsponsored: true, + expectedIndex: 4, + }, + { + sponsored: false, + nonsponsored: true, + expectedIndex: undefined, + }, + { + sponsored: true, + nonsponsored: false, + expectedIndex: 4, + }, + { + sponsored: false, + nonsponsored: false, + expectedIndex: undefined, + }, + ], + }, +}; + +add_task(async function () { + // Create results and suggestions based on `SUGGESTIONS_DATA`. + let qsResults = []; + let qsSuggestions = []; + let urlbarResults = []; + for (let i = 0; i < SUGGESTIONS_DATA.length; i++) { + let { keywords, isSponsored, score } = SUGGESTIONS_DATA[i]; + + // quick suggest result + let qsResult = { + keywords, + score, + id: i, + url: "http://example.com/" + i, + title: "Title " + i, + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: isSponsored ? "22 - Shopping" : "5 - Education", + }; + qsResults.push(qsResult); + + // expected quick suggest suggestion + let qsSuggestion = { + ...qsResult, + score, + block_id: qsResult.id, + is_sponsored: isSponsored, + source: "remote-settings", + icon: null, + position: undefined, + provider: "AdmWikipedia", + }; + delete qsSuggestion.keywords; + delete qsSuggestion.id; + qsSuggestions.push(qsSuggestion); + + // expected urlbar result + urlbarResults.push({ + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + isSponsored, + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + sponsoredBlockId: qsResult.id, + url: qsResult.url, + originalUrl: qsResult.url, + displayUrl: qsResult.url, + title: qsResult.title, + sponsoredClickUrl: qsResult.click_url, + sponsoredImpressionUrl: qsResult.impression_url, + sponsoredAdvertiser: qsResult.advertiser, + sponsoredIabCategory: qsResult.iab_category, + icon: null, + descriptionL10n: isSponsored + ? { id: "urlbar-result-action-sponsored" } + : undefined, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + source: "remote-settings", + provider: "AdmWikipedia", + }, + }); + } + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: qsResults, + }, + ], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); + + // Run a test for each keyword. + for (let [keyword, test] of Object.entries(TESTS)) { + info("Running subtest " + JSON.stringify({ keyword, test })); + + let { expectedIndexes, searches } = test; + + // Call `query()`. + Assert.deepEqual( + await QuickSuggest.jsBackend.query(keyword), + expectedIndexes.map(i => ({ + ...qsSuggestions[i], + full_keyword: keyword, + })), + `query() for keyword ${keyword}` + ); + + // Now do a urlbar search for the keyword with all possible combinations of + // sponsored and non-sponsored suggestions enabled and disabled. + for (let sponsored of [true, false]) { + for (let nonsponsored of [true, false]) { + // Find the matching `searches` object. + let search = searches.find( + s => s.sponsored == sponsored && s.nonsponsored == nonsponsored + ); + Assert.ok( + search, + "Sanity check: Search test case specified for " + + JSON.stringify({ keyword, sponsored, nonsponsored }) + ); + + info( + "Running urlbar search subtest " + + JSON.stringify({ keyword, expectedIndexes, search }) + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", sponsored); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", nonsponsored); + await QuickSuggestTestUtils.forceSync(); + + // Set up the search and do it. + let context = createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + let matches = []; + if (search.expectedIndex !== undefined) { + matches.push({ + ...urlbarResults[search.expectedIndex], + payload: { + ...urlbarResults[search.expectedIndex].payload, + qsSuggestion: keyword, + }, + }); + } + + await check_results({ context, matches }); + } + } + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + await QuickSuggestTestUtils.forceSync(); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js new file mode 100644 index 0000000000..c01792e321 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js @@ -0,0 +1,127 @@ +/* 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 `UrlbarPrefs.updateFirefoxSuggestScenario` in isolation under the +// assumption that the offline scenario should be enabled by default for US en. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Region: "resource://gre/modules/Region.sys.mjs", +}); + +// All the prefs that `updateFirefoxSuggestScenario` sets along with the +// expected default-branch values when offline is enabled and when it's not +// enabled. +const PREFS = [ + { + name: "browser.urlbar.quicksuggest.enabled", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, + { + name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: false, + expectedOtherValue: true, + }, + { + name: "browser.urlbar.suggest.quicksuggest.nonsponsored", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, + { + name: "browser.urlbar.suggest.quicksuggest.sponsored", + get: "getBoolPref", + set: "setBoolPref", + expectedOfflineValue: true, + expectedOtherValue: false, + }, +]; + +add_setup(async () => { + await UrlbarTestUtils.initNimbusFeature(); +}); + +add_task(async function test() { + let tests = [ + { locale: "en-US", home: "US", expectedOfflineDefault: true }, + { locale: "en-US", home: "CA", expectedOfflineDefault: false }, + { locale: "en-CA", home: "US", expectedOfflineDefault: true }, + { locale: "en-CA", home: "CA", expectedOfflineDefault: false }, + { locale: "en-GB", home: "US", expectedOfflineDefault: true }, + { locale: "en-GB", home: "GB", expectedOfflineDefault: false }, + { locale: "de", home: "US", expectedOfflineDefault: false }, + { locale: "de", home: "DE", expectedOfflineDefault: false }, + ]; + for (let { locale, home, expectedOfflineDefault } of tests) { + await doTest({ locale, home, expectedOfflineDefault }); + } +}); + +/** + * Sets the app's locale and region, calls + * `UrlbarPrefs.updateFirefoxSuggestScenario`, and asserts that the pref values + * are correct. + * + * @param {object} options + * Options object. + * @param {string} options.locale + * The locale to simulate. + * @param {string} options.home + * The "home" region to simulate. + * @param {boolean} options.expectedOfflineDefault + * The expected value of whether offline should be enabled by default given + * the locale and region. + */ +async function doTest({ locale, home, expectedOfflineDefault }) { + // Setup: Clear any user values and save original default-branch values. + for (let pref of PREFS) { + Services.prefs.clearUserPref(pref.name); + pref.originalDefault = Services.prefs + .getDefaultBranch(pref.name) + [pref.get](""); + } + + // Set the region and locale, call the function, check the pref values. + Region._setHomeRegion(home, false); + await QuickSuggestTestUtils.withLocales([locale], async () => { + await UrlbarPrefs.updateFirefoxSuggestScenario(); + for (let { name, get, expectedOfflineValue, expectedOtherValue } of PREFS) { + let expectedValue = expectedOfflineDefault + ? expectedOfflineValue + : expectedOtherValue; + + // Check the default-branch value. + Assert.strictEqual( + Services.prefs.getDefaultBranch(name)[get](""), + expectedValue, + `Default pref value for ${name}, locale ${locale}, home ${home}` + ); + + // For good measure, also check the return value of `UrlbarPrefs.get` + // since we use it everywhere. The value should be the same as the + // default-branch value. + UrlbarPrefs.get( + name.replace("browser.urlbar.", ""), + expectedValue, + `UrlbarPrefs.get() value for ${name}, locale ${locale}, home ${home}` + ); + } + }); + + // Teardown: Restore original default-branch values for the next task. + for (let { name, originalDefault, set } of PREFS) { + if (originalDefault === undefined) { + Services.prefs.deleteBranch(name); + } else { + Services.prefs.getDefaultBranch(name)[set]("", originalDefault); + } + } +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js new file mode 100644 index 0000000000..29133a8579 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js @@ -0,0 +1,531 @@ +/* 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 Pocket quick suggest results. + +"use strict"; + +const LOW_KEYWORD = "low one two"; +const HIGH_KEYWORD = "high three"; + +const REMOTE_SETTINGS_DATA = [ + { + type: "pocket-suggestions", + attachment: [ + { + url: "https://example.com/pocket-0", + title: "Pocket Suggestion 0", + description: "Pocket description 0", + lowConfidenceKeywords: [LOW_KEYWORD, "how to low"], + highConfidenceKeywords: [HIGH_KEYWORD], + score: 0.25, + }, + { + url: "https://example.com/pocket-1", + title: "Pocket Suggestion 1", + description: "Pocket description 1", + lowConfidenceKeywords: ["other low"], + highConfidenceKeywords: ["another high"], + score: 0.25, + }, + ], + }, +]; + +add_setup(async () => { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_DATA, + prefs: [ + ["suggest.quicksuggest.nonsponsored", true], + ["pocket.featureGate", true], + ], + }); +}); + +add_task(async function telemetryType() { + Assert.equal( + QuickSuggest.getFeature("PocketSuggestions").getSuggestionTelemetryType({}), + "pocket", + "Telemetry type should be 'pocket'" + ); +}); + +// When non-sponsored suggestions are disabled, Pocket suggestions should be +// disabled. +add_tasks_with_rust(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Pocket suggestions are non-sponsored, so + // doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ searchString: LOW_KEYWORD })], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); + await QuickSuggestTestUtils.forceSync(); +}); + +// When Pocket-specific preferences are disabled, suggestions should not be +// added. +add_tasks_with_rust(async function pocketSpecificPrefsDisabled() { + const prefs = ["suggest.pocket", "pocket.featureGate"]; + for (const pref of prefs) { + // First make sure the suggestion is added. + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ searchString: LOW_KEYWORD })], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + } +}); + +// Check wheather the Pocket suggestions will be shown by the setup of Nimbus +// variable. +add_tasks_with_rust(async function nimbus() { + // Disable the fature gate. + UrlbarPrefs.set("pocket.featureGate", false); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + pocketFeatureGate: true, + }); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ searchString: LOW_KEYWORD })], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("pocket.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({ + pocketFeatureGate: false, + }); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + UrlbarPrefs.set("pocket.featureGate", true); + await QuickSuggestTestUtils.forceSync(); +}); + +// The suggestion should be shown as a top pick when a high-confidence keyword +// is matched. +add_tasks_with_rust(async function topPick() { + await check_results({ + context: createContext(HIGH_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ searchString: HIGH_KEYWORD, isTopPick: true }), + ], + }); +}); + +// Low-confidence keywords should do prefix matching starting at the first word. +add_tasks_with_rust(async function lowPrefixes() { + // search string -> should match + let tests = { + l: false, + lo: false, + low: true, + "low ": true, + "low o": true, + "low on": true, + "low one": true, + "low one ": true, + "low one t": true, + "low one tw": true, + "low one two": true, + "low one two ": false, + }; + for (let [searchString, shouldMatch] of Object.entries(tests)) { + info("Doing search: " + JSON.stringify({ searchString, shouldMatch })); + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: shouldMatch + ? [makeExpectedResult({ searchString, fullKeyword: LOW_KEYWORD })] + : [], + }); + } +}); + +// Low-confidence keywords that start with "how to" should do prefix matching +// starting at "how to" instead of the first word. +// +// Note: The Rust implementation doesn't support this. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function lowPrefixes_howTo() { + // search string -> should match + let tests = { + h: false, + ho: false, + how: false, + "how ": false, + "how t": false, + "how to": true, + "how to ": true, + "how to l": true, + "how to lo": true, + "how to low": true, + }; + for (let [searchString, shouldMatch] of Object.entries(tests)) { + info("Doing search: " + JSON.stringify({ searchString, shouldMatch })); + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: shouldMatch + ? [makeExpectedResult({ searchString, fullKeyword: "how to low" })] + : [], + }); + } + } +); + +// High-confidence keywords should not do prefix matching at all. +add_tasks_with_rust(async function highPrefixes() { + // search string -> should match + let tests = { + h: false, + hi: false, + hig: false, + high: false, + "high ": false, + "high t": false, + "high th": false, + "high thr": false, + "high thre": false, + "high three": true, + "high three ": false, + }; + for (let [searchString, shouldMatch] of Object.entries(tests)) { + info("Doing search: " + JSON.stringify({ searchString, shouldMatch })); + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: shouldMatch + ? [ + makeExpectedResult({ + searchString, + fullKeyword: HIGH_KEYWORD, + isTopPick: true, + }), + ] + : [], + }); + } +}); + +// Keyword matching should be case insenstive. +add_tasks_with_rust(async function uppercase() { + await check_results({ + context: createContext(LOW_KEYWORD.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + searchString: LOW_KEYWORD.toUpperCase(), + fullKeyword: LOW_KEYWORD, + }), + ], + }); + await check_results({ + context: createContext(HIGH_KEYWORD.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + searchString: HIGH_KEYWORD.toUpperCase(), + fullKeyword: HIGH_KEYWORD, + isTopPick: true, + }), + ], + }); +}); + +// Tests the "Not relevant" command: a dismissed suggestion shouldn't be added. +add_tasks_with_rust(async function notRelevant() { + let result = makeExpectedResult({ searchString: LOW_KEYWORD }); + + info("Triggering the 'Not relevant' command"); + QuickSuggest.getFeature("PocketSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_relevant" + ); + await QuickSuggest.blockedSuggestions._test_readyPromise; + + Assert.ok( + await QuickSuggest.blockedSuggestions.has(result.payload.originalUrl), + "The result's URL should be blocked" + ); + + info("Doing search for blocked suggestion"); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for blocked suggestion using high-confidence keyword"); + await check_results({ + context: createContext(HIGH_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for a suggestion that wasn't blocked"); + await check_results({ + context: createContext("other low", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + searchString: "other low", + suggestion: REMOTE_SETTINGS_DATA[0].attachment[1], + }), + ], + }); + + info("Clearing blocked suggestions"); + await QuickSuggest.blockedSuggestions.clear(); + + info("Doing search for unblocked suggestion"); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); +}); + +// Tests the "Not interested" command: all Pocket suggestions should be disabled +// and not added anymore. +add_tasks_with_rust(async function notInterested() { + let result = makeExpectedResult({ searchString: LOW_KEYWORD }); + + info("Triggering the 'Not interested' command"); + QuickSuggest.getFeature("PocketSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_interested" + ); + + Assert.ok( + !UrlbarPrefs.get("suggest.pocket"), + "Pocket suggestions should be disabled" + ); + + info("Doing search for the suggestion the command was used on"); + await check_results({ + context: createContext(LOW_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for another Pocket suggestion"); + await check_results({ + context: createContext("other low", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.clear("suggest.pocket"); + await QuickSuggestTestUtils.forceSync(); +}); + +// Tests the "show less frequently" behavior. +add_tasks_with_rust(async function showLessFrequently() { + await doShowLessFrequentlyTests({ + feature: QuickSuggest.getFeature("PocketSuggestions"), + showLessFrequentlyCountPref: "pocket.showLessFrequentlyCount", + nimbusCapVariable: "pocketShowLessFrequentlyCap", + expectedResult: searchString => + makeExpectedResult({ searchString, fullKeyword: LOW_KEYWORD }), + keyword: LOW_KEYWORD, + }); +}); + +// The `Pocket` Rust provider should be passed to the Rust component when +// querying depending on whether Pocket suggestions are enabled. +add_task(async function rustProviders() { + // TODO bug 1874074: The Rust component fetches Pocket suggestions when the + // AMO provider is specified regardless of whether the Pocket provider is + // specified. AMO suggestions are enabled by default, so disable them first so + // that the Rust backend does not pass in the AMO provider. + UrlbarPrefs.set("suggest.addons", false); + + await doRustProvidersTests({ + searchString: LOW_KEYWORD, + tests: [ + { + prefs: { + "suggest.pocket": true, + }, + expectedUrls: ["https://example.com/pocket-0"], + }, + { + prefs: { + "suggest.pocket": false, + }, + expectedUrls: [], + }, + ], + }); + + UrlbarPrefs.clear("suggest.addons"); + UrlbarPrefs.clear("suggest.pocket"); + await QuickSuggestTestUtils.forceSync(); +}); + +function makeExpectedResult({ + searchString, + fullKeyword = searchString, + suggestion = REMOTE_SETTINGS_DATA[0].attachment[0], + source = "remote-settings", + isTopPick = false, +} = {}) { + if ( + source == "remote-settings" && + UrlbarPrefs.get("quicksuggest.rustEnabled") + ) { + source = "rust"; + } + + let provider; + let keywordSubstringNotTyped = fullKeyword.substring(searchString.length); + let description = suggestion.description; + switch (source) { + case "remote-settings": + provider = "PocketSuggestions"; + break; + case "rust": + provider = "Pocket"; + // Rust suggestions currently do not include full keyword or description. + keywordSubstringNotTyped = ""; + description = suggestion.title; + break; + case "merino": + provider = "pocket"; + break; + } + + let url = new URL(suggestion.url); + url.searchParams.set("utm_medium", "firefox-desktop"); + url.searchParams.set("utm_source", "firefox-suggest"); + url.searchParams.set("utm_campaign", "pocket-collections-in-the-address-bar"); + url.searchParams.set("utm_content", "treatment"); + + return { + isBestMatch: isTopPick, + suggestedIndex: isTopPick ? 1 : -1, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK, + heuristic: false, + payload: { + source, + provider, + telemetryType: "pocket", + title: suggestion.title, + url: url.href, + displayUrl: url.href.replace(/^https:\/\//, ""), + originalUrl: suggestion.url, + description: isTopPick ? description : "", + icon: isTopPick + ? "chrome://global/skin/icons/pocket.svg" + : "chrome://global/skin/icons/pocket-favicon.ico", + helpUrl: QuickSuggest.HELP_URL, + shouldShowUrl: true, + bottomTextL10n: { + id: "firefox-suggest-pocket-bottom-text", + args: { + keywordSubstringTyped: searchString, + keywordSubstringNotTyped, + }, + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js new file mode 100644 index 0000000000..d1845a9b22 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js @@ -0,0 +1,487 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests for quick suggest result position specified in suggestions. + */ + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderHeuristicFallback: + "resource:///modules/UrlbarProviderHeuristicFallback.sys.mjs", + UrlbarProviderPlaces: "resource:///modules/UrlbarProviderPlaces.sys.mjs", + UrlbarProviderTabToSearch: + "resource:///modules/UrlbarProviderTabToSearch.sys.mjs", +}); + +const SPONSORED_SECOND_POSITION_RESULT = { + id: 1, + url: "http://example.com/?q=sponsored-second", + title: "sponsored second", + keywords: ["s-s"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + position: 1, +}; +const SPONSORED_NORMAL_POSITION_RESULT = { + id: 2, + url: "http://example.com/?q=sponsored-normal", + title: "sponsored normal", + keywords: ["s-n"], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", +}; +const NONSPONSORED_SECOND_POSITION_RESULT = { + id: 3, + url: "http://example.com/?q=nonsponsored-second", + title: "nonsponsored second", + keywords: ["n-s"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", + position: 1, +}; +const NONSPONSORED_NORMAL_POSITION_RESULT = { + id: 4, + url: "http://example.com/?q=nonsponsored-normal", + title: "nonsponsored normal", + keywords: ["n-n"], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", +}; +const FIRST_POSITION_RESULT = { + id: 5, + url: "http://example.com/?q=first-position", + title: "first position suggest", + keywords: ["first-position"], + click_url: "http://click.reporting.test.com/first-position", + impression_url: "http://impression.reporting.test.com/first-position", + advertiser: "TestAdvertiserFirstPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 0, +}; +const SECOND_POSITION_RESULT = { + id: 6, + url: "http://example.com/?q=second-position", + title: "second position suggest", + keywords: ["second-position"], + click_url: "http://click.reporting.test.com/second-position", + impression_url: "http://impression.reporting.test.com/second-position", + advertiser: "TestAdvertiserSecondPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 1, +}; +const THIRD_POSITION_RESULT = { + id: 7, + url: "http://example.com/?q=third-position", + title: "third position suggest", + keywords: ["third-position"], + click_url: "http://click.reporting.test.com/third-position", + impression_url: "http://impression.reporting.test.com/third-position", + advertiser: "TestAdvertiserThirdPositionQuickSuggest", + iab_category: "22 - Shopping", + position: 2, +}; + +const TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST = + "first-position.example.com"; +const TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST = + "second-position.example.com"; + +const SECOND_POSITION_INTERVENTION_RESULT = new UrlbarResult( + UrlbarUtils.RESULT_TYPE.URL, + UrlbarUtils.RESULT_SOURCE.HISTORY, + { url: "http://mozilla.org/a" } +); +SECOND_POSITION_INTERVENTION_RESULT.suggestedIndex = 1; +const SECOND_POSITION_INTERVENTION_RESULT_PROVIDER = + new UrlbarTestUtils.TestProvider({ + results: [SECOND_POSITION_INTERVENTION_RESULT], + priority: 0, + name: "second_position_intervention_provider", + }); + +const EXPECTED_GENERAL_HEURISTIC_RESULT = { + providerName: UrlbarProviderHeuristicFallback.name, + type: UrlbarUtils.RESULT_TYPE.SEARCH, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: true, +}; + +const EXPECTED_GENERAL_PLACES_RESULT = { + providerName: UrlbarProviderPlaces.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: false, +}; + +const EXPECTED_GENERAL_TABTOSEARCH_RESULT = { + providerName: UrlbarProviderTabToSearch.name, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, +}; + +const EXPECTED_GENERAL_INTERVENTION_RESULT = { + providerName: SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.HISTORY, + heuristic: false, +}; + +function createExpectedQuickSuggestResult(suggest) { + let isSponsored = suggest.iab_category !== "5 - Education"; + return { + providerName: UrlbarProviderQuickSuggest.name, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + qsSuggestion: suggest.keywords[0], + title: suggest.title, + url: suggest.url, + originalUrl: suggest.url, + icon: null, + sponsoredImpressionUrl: suggest.impression_url, + sponsoredClickUrl: suggest.click_url, + sponsoredBlockId: suggest.id, + sponsoredAdvertiser: suggest.advertiser, + sponsoredIabCategory: suggest.iab_category, + isSponsored, + descriptionL10n: isSponsored + ? { id: "urlbar-result-action-sponsored" } + : undefined, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + displayUrl: suggest.url, + source: "remote-settings", + provider: "AdmWikipedia", + }, + }; +} + +const TEST_CASES = [ + { + description: "Test for second placable sponsored suggest", + input: SPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for normal sponsored suggest", + input: SPONSORED_NORMAL_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(SPONSORED_NORMAL_POSITION_RESULT), + ], + }, + { + description: "Test for second placable nonsponsored suggest", + input: NONSPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(NONSPONSORED_SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for normal nonsponsored suggest", + input: NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(NONSPONSORED_NORMAL_POSITION_RESULT), + ], + }, + { + description: + "Test for second placable sponsored suggest but secondPosition pref is disabled", + input: SPONSORED_SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": false, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with multi providers having same index", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderTabToSearch.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: "Test the results with tab-to-search", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with another intervention", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: "Test the results with heuristic and tab-to-search", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + ], + }, + { + description: "Test the results with heuristic tab-to-search and places", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test the results with heuristic and another intervention", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + ], + }, + { + description: + "Test the results with heuristic, another intervention and places", + input: SECOND_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderHeuristicFallback.name, + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + UrlbarProviderPlaces.name, + ], + expected: [ + EXPECTED_GENERAL_HEURISTIC_RESULT, + createExpectedQuickSuggestResult(SECOND_POSITION_RESULT), + EXPECTED_GENERAL_INTERVENTION_RESULT, + EXPECTED_GENERAL_PLACES_RESULT, + ], + }, + { + description: "Test for 0 indexed quick suggest", + input: FIRST_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderTabToSearch.name, + UrlbarProviderQuickSuggest.name, + ], + expected: [ + createExpectedQuickSuggestResult(FIRST_POSITION_RESULT), + EXPECTED_GENERAL_TABTOSEARCH_RESULT, + ], + }, + { + description: "Test for 2 indexed quick suggest", + input: THIRD_POSITION_RESULT.keywords[0], + prefs: { + "quicksuggest.allowPositionInSuggestions": true, + }, + providers: [ + UrlbarProviderQuickSuggest.name, + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name, + ], + expected: [ + EXPECTED_GENERAL_INTERVENTION_RESULT, + createExpectedQuickSuggestResult(THIRD_POSITION_RESULT), + ], + }, +]; + +add_setup(async function () { + // Setup for quick suggest result. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: [ + SPONSORED_SECOND_POSITION_RESULT, + SPONSORED_NORMAL_POSITION_RESULT, + NONSPONSORED_SECOND_POSITION_RESULT, + NONSPONSORED_NORMAL_POSITION_RESULT, + FIRST_POSITION_RESULT, + SECOND_POSITION_RESULT, + THIRD_POSITION_RESULT, + ], + }, + ], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); + + // Setup for places result. + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits([ + "http://example.com/" + SPONSORED_SECOND_POSITION_RESULT.keywords[0], + "http://example.com/" + SPONSORED_NORMAL_POSITION_RESULT.keywords[0], + "http://example.com/" + NONSPONSORED_SECOND_POSITION_RESULT.keywords[0], + "http://example.com/" + NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0], + "http://example.com/" + SECOND_POSITION_RESULT.keywords[0], + ]); + + // Setup for tab-to-search result. + await SearchTestUtils.installSearchExtension({ + name: "first", + search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST}/`, + }); + await SearchTestUtils.installSearchExtension({ + name: "second", + search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST}/`, + }); + + /// Setup for another intervention result. + UrlbarProvidersManager.registerProvider( + SECOND_POSITION_INTERVENTION_RESULT_PROVIDER + ); +}); + +add_task(async function basic() { + for (const { description, input, prefs, providers, expected } of TEST_CASES) { + info(description); + + for (let name in prefs) { + UrlbarPrefs.set(name, prefs[name]); + } + + const context = createContext(input, { + providers, + isPrivate: false, + }); + await check_results({ + context, + matches: expected, + }); + + for (let name in prefs) { + UrlbarPrefs.clear(name); + } + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js new file mode 100644 index 0000000000..224dd6cb22 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js @@ -0,0 +1,670 @@ +/* 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 `quickSuggestScoreMap` Nimbus variable that assigns scores to +// specified types of quick suggest suggestions. The scores in the map should +// override the scores in the individual suggestion objects so that experiments +// can fully control the relative ranking of suggestions. + +"use strict"; + +const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest; + +const REMOTE_SETTINGS_RECORDS = [ + { + type: "data", + attachment: [ + // sponsored without score + QuickSuggestTestUtils.ampRemoteSettings({ + score: undefined, + keywords: [ + "sponsored without score", + "sponsored without score, nonsponsored without score", + "sponsored without score, nonsponsored with score", + "sponsored without score, addon without score", + ], + url: "https://example.com/sponsored-without-score", + title: "Sponsored without score", + }), + // sponsored with score + QuickSuggestTestUtils.ampRemoteSettings({ + score: 2 * DEFAULT_SUGGESTION_SCORE, + keywords: [ + "sponsored with score", + "sponsored with score, nonsponsored without score", + "sponsored with score, nonsponsored with score", + "sponsored with score, addon with score", + ], + url: "https://example.com/sponsored-with-score", + title: "Sponsored with score", + }), + // nonsponsored without score + QuickSuggestTestUtils.wikipediaRemoteSettings({ + score: undefined, + keywords: [ + "nonsponsored without score", + "sponsored without score, nonsponsored without score", + "sponsored with score, nonsponsored without score", + ], + url: "https://example.com/nonsponsored-without-score", + title: "Nonsponsored without score", + }), + // nonsponsored with score + QuickSuggestTestUtils.wikipediaRemoteSettings({ + score: 2 * DEFAULT_SUGGESTION_SCORE, + keywords: [ + "nonsponsored with score", + "sponsored without score, nonsponsored with score", + "sponsored with score, nonsponsored with score", + ], + url: "https://example.com/nonsponsored-with-score", + title: "Nonsponsored with score", + }), + ], + }, + { + type: "amo-suggestions", + attachment: [ + // addon with score + QuickSuggestTestUtils.amoRemoteSettings({ + score: 2 * DEFAULT_SUGGESTION_SCORE, + keywords: [ + "addon with score", + "sponsored with score, addon with score", + ], + url: "https://example.com/addon-with-score", + title: "Addon with score", + }), + ], + }, +]; + +const ADM_RECORD = REMOTE_SETTINGS_RECORDS[0]; +const SPONSORED_WITHOUT_SCORE = ADM_RECORD.attachment[0]; +const SPONSORED_WITH_SCORE = ADM_RECORD.attachment[1]; +const NONSPONSORED_WITHOUT_SCORE = ADM_RECORD.attachment[2]; +const NONSPONSORED_WITH_SCORE = ADM_RECORD.attachment[3]; + +const ADDON_RECORD = REMOTE_SETTINGS_RECORDS[1]; +const ADDON_WITH_SCORE = ADDON_RECORD.attachment[0]; + +const MERINO_SPONSORED_SUGGESTION = { + provider: "adm", + score: DEFAULT_SUGGESTION_SCORE, + iab_category: "22 - Shopping", + is_sponsored: true, + keywords: ["test"], + full_keyword: "test", + block_id: 1, + url: "https://example.com/merino-sponsored", + title: "Merino sponsored", + click_url: "https://example.com/click", + impression_url: "https://example.com/impression", + advertiser: "TestAdvertiser", + icon: "1234", +}; + +const MERINO_ADDON_SUGGESTION = { + provider: "amo", + score: DEFAULT_SUGGESTION_SCORE, + keywords: ["test"], + icon: "https://example.com/addon.svg", + url: "https://example.com/merino-addon", + title: "Merino addon", + description: "Merino addon", + custom_details: { + amo: { + guid: "merino-addon@example.com", + rating: "4.7", + number_of_ratings: "1256", + }, + }, +}; + +const MERINO_UNKNOWN_SUGGESTION = { + provider: "some_unknown_provider", + score: DEFAULT_SUGGESTION_SCORE, + keywords: ["test"], + url: "https://example.com/merino-unknown", + title: "Merino unknown", +}; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_RECORDS, + merinoSuggestions: [], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["suggest.quicksuggest.nonsponsored", true], + ], + }); +}); + +add_task(async function sponsoredWithout_nonsponsoredWithout_sponsoredWins() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); +}); + +add_task( + async function sponsoredWithout_nonsponsoredWithout_nonsponsoredWins() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITHOUT_SCORE, + }), + }); + } +); + +add_task( + async function sponsoredWithout_nonsponsoredWithout_sponsoredWins_both() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + adm_nonsponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); + } +); + +add_task( + async function sponsoredWithout_nonsponsoredWithout_nonsponsoredWins_both() { + let keyword = "sponsored without score, nonsponsored without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + adm_sponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITHOUT_SCORE, + }), + }); + } +); + +add_task(async function sponsoredWith_nonsponsoredWith_sponsoredWins() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_nonsponsoredWith_nonsponsoredWins() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_nonsponsoredWith_sponsoredWins_both() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + adm_nonsponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_nonsponsoredWith_nonsponsoredWins_both() { + let keyword = "sponsored with score, nonsponsored with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_nonsponsored: score, + adm_sponsored: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedWikipediaResult({ + keyword, + suggestion: NONSPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWithout_addonWithout_sponsoredWins() { + let keyword = "sponsored without score, addon without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); +}); + +add_task(async function sponsoredWithout_addonWithout_sponsoredWins_both() { + let keyword = "sponsored without score, addon without score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + amo: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITHOUT_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_sponsoredWins() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_addonWins() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + amo: score, + }, + expectedFeatureName: "AddonSuggestions", + expectedScore: score, + expectedResult: makeExpectedAddonResult({ + suggestion: ADDON_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_sponsoredWins_both() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: score, + amo: score / 2, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +add_task(async function sponsoredWith_addonWith_addonWins_both() { + let keyword = "sponsored with score, addon with score"; + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword, + scoreMap: { + amo: score, + adm_sponsored: score / 2, + }, + expectedFeatureName: "AddonSuggestions", + expectedScore: score, + expectedResult: makeExpectedAddonResult({ + suggestion: ADDON_WITH_SCORE, + }), + }); +}); + +add_task(async function merino_sponsored_addon_sponsoredWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_ADDON_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword: "test", + suggestion: MERINO_SPONSORED_SUGGESTION, + source: "merino", + provider: "adm", + requestId: MerinoTestUtils.server.response.body.request_id, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function merino_sponsored_addon_addonWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_ADDON_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + amo: score, + }, + expectedFeatureName: "AddonSuggestions", + expectedScore: score, + expectedResult: makeExpectedAddonResult({ + suggestion: MERINO_ADDON_SUGGESTION, + source: "merino", + provider: "amo", + requestId: MerinoTestUtils.server.response.body.request_id, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function merino_sponsored_unknown_sponsoredWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_UNKNOWN_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + adm_sponsored: score, + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: score, + expectedResult: makeExpectedAdmResult({ + keyword: "test", + suggestion: MERINO_SPONSORED_SUGGESTION, + source: "merino", + provider: "adm", + requestId: MerinoTestUtils.server.response.body.request_id, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function merino_sponsored_unknown_unknownWins() { + await QuickSuggestTestUtils.setRemoteSettingsRecords([]); + + MerinoTestUtils.server.response.body.suggestions = [ + MERINO_SPONSORED_SUGGESTION, + MERINO_UNKNOWN_SUGGESTION, + ]; + + let score = 10 * DEFAULT_SUGGESTION_SCORE; + await doTest({ + keyword: "test", + scoreMap: { + [MERINO_UNKNOWN_SUGGESTION.provider]: score, + }, + expectedFeatureName: null, + expectedScore: score, + expectedResult: makeExpectedDefaultResult({ + suggestion: MERINO_UNKNOWN_SUGGESTION, + }), + }); + + await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS); +}); + +add_task(async function stringValue() { + let keyword = "sponsored with score, nonsponsored with score"; + await doTest({ + keyword, + scoreMap: { + adm_sponsored: "123.456", + }, + expectedFeatureName: "AdmWikipedia", + expectedScore: 123.456, + expectedResult: makeExpectedAdmResult({ + keyword, + suggestion: SPONSORED_WITH_SCORE, + }), + }); +}); + +/** + * Sets up Nimbus with a `quickSuggestScoreMap` variable value, does a search, + * and makes sure the expected result is shown and the expected score is set on + * the suggestion. + * + * @param {object} options + * Options object. + * @param {string} options.keyword + * The search string. This should be equal to a keyword from one or more + * suggestions. + * @param {object} options.scoreMap + * The value to set for the `quickSuggestScoreMap` variable. + * @param {string} options.expectedFeatureName + * The name of the `BaseFeature` instance that is expected to create the + * `UrlbarResult` that's shown. If the suggestion is intentionally from an + * unknown Merino provider and therefore the quick suggest provider is + * expected to create a default result for it, set this to null. + * @param {UrlbarResultstring} options.expectedResult + * The `UrlbarResult` that's expected to be shown. + * @param {number} options.expectedScore + * The final `score` value that's expected to be defined on the suggestion + * object. + */ +async function doTest({ + keyword, + scoreMap, + expectedFeatureName, + expectedResult, + expectedScore, +}) { + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + quickSuggestScoreMap: scoreMap, + }); + + // Stub the expected feature's `makeResult()` so we can see the value of the + // passed-in suggestion's score. If the suggestion's type is in the score map, + // the provider will set its score before calling `makeResult()`. + let actualScore; + let sandbox; + if (expectedFeatureName) { + sandbox = sinon.createSandbox(); + let feature = QuickSuggest.getFeature(expectedFeatureName); + let stub = sandbox + .stub(feature, "makeResult") + .callsFake((queryContext, suggestion, searchString) => { + actualScore = suggestion.score; + return stub.wrappedMethod.call( + feature, + queryContext, + suggestion, + searchString + ); + }); + } + + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [expectedResult], + }); + + if (expectedFeatureName) { + Assert.equal( + actualScore, + expectedScore, + "Suggestion score should be set correctly" + ); + sandbox.restore(); + } + + await cleanUpNimbus(); +} + +function makeExpectedAdmResult({ + suggestion, + keyword, + source, + provider, + requestId, +}) { + return makeAmpResult({ + keyword, + source, + provider, + requestId, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + advertiser: suggestion.advertiser, + icon: suggestion.icon, + }); +} + +function makeExpectedWikipediaResult({ suggestion, keyword, source }) { + return makeWikipediaResult({ + keyword, + source, + title: suggestion.title, + url: suggestion.url, + originalUrl: suggestion.url, + impressionUrl: suggestion.impression_url, + clickUrl: suggestion.click_url, + blockId: suggestion.id, + }); +} + +function makeExpectedAddonResult({ suggestion, source, provider }) { + return makeAmoResult({ + source, + provider, + title: suggestion.title, + description: suggestion.description, + url: suggestion.url, + originalUrl: suggestion.url, + icon: suggestion.icon, + }); +} + +function makeExpectedDefaultResult({ suggestion }) { + return { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + source: "merino", + provider: suggestion.provider, + telemetryType: suggestion.provider, + isSponsored: suggestion.is_sponsored, + title: suggestion.title, + url: suggestion.url, + displayUrl: suggestion.url.replace(/^https:\/\//, ""), + icon: suggestion.icon, + descriptionL10n: suggestion.is_sponsored + ? { id: "urlbar-result-action-sponsored" } + : undefined, + shouldShowUrl: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js new file mode 100644 index 0000000000..1b8da54920 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js @@ -0,0 +1,192 @@ +/* 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 top pick quick suggest results. "Top picks" refers to two different +// concepts: +// +// (1) Any type of suggestion from Merino can have a boolean property called +// `is_top_pick`. When true, Firefox should show the suggestion using the +// "best match" UI treatment (labeled "top pick" in the UI) that makes a +// result's row larger than usual and sets `suggestedIndex` to 1. +// (2) There is a Merino provider called "top_picks" that returns a specific +// type of suggestion called "navigational suggestions". These suggestions +// also have `is_top_pick` set to true. +// +// This file tests aspects of both concepts. + +"use strict"; + +const SUGGESTION_SEARCH_STRING = "example"; +const SUGGESTION_URL = "http://example.com/"; +const SUGGESTION_URL_WWW = "http://www.example.com/"; +const SUGGESTION_URL_DISPLAY = "http://example.com"; + +const MERINO_SUGGESTIONS = [ + { + is_top_pick: true, + provider: "top_picks", + url: SUGGESTION_URL, + title: "title", + icon: "icon", + is_sponsored: false, + score: 1, + }, +]; + +add_setup(async function init() { + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); +}); + +// When non-sponsored suggestions are disabled, navigational suggestions should +// be disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Navigational suggestions are non-sponsored, + // so doing this should not prevent them from being enabled. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + // First make sure the suggestion is added when non-sponsored suggestions are + // enabled. + await check_results({ + context: createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + isBestMatch: true, + suggestedIndex: 1, + }), + ], + }); + + // Now disable them. + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + await check_results({ + context: createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.sponsored"); +}); + +// Test that bestMatch navigational suggestion results are not shown when there +// is a heuristic result for the same domain. +add_task(async function heuristicDeduplication() { + let expectedNavSuggestResult = makeExpectedResult({ + isBestMatch: true, + suggestedIndex: 1, + dupedHeuristic: false, + }); + + let scenarios = [ + [SUGGESTION_URL, false], + [SUGGESTION_URL_WWW, false], + ["http://exampledomain.com/", true], + ]; + + // Stub `UrlbarProviderQuickSuggest.startQuery()` so we can collect the + // results it adds for each query. + let addedResults = []; + let sandbox = sinon.createSandbox(); + let startQueryStub = sandbox.stub(UrlbarProviderQuickSuggest, "startQuery"); + startQueryStub.callsFake((queryContext, add) => { + let fakeAdd = (provider, result) => { + addedResults.push(result); + add(provider, result); + }; + return startQueryStub.wrappedMethod.call( + UrlbarProviderQuickSuggest, + queryContext, + fakeAdd + ); + }); + + for (let [url, expectBestMatch] of scenarios) { + await PlacesTestUtils.addVisits(url); + + // Do a search and check the results. + let context = createContext(SUGGESTION_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderAutofill.name], + isPrivate: false, + }); + const EXPECTED_AUTOFILL_RESULT = makeVisitResult(context, { + uri: url, + title: `test visit for ${url}`, + heuristic: true, + }); + await check_results({ + context, + matches: expectBestMatch + ? [EXPECTED_AUTOFILL_RESULT, expectedNavSuggestResult] + : [EXPECTED_AUTOFILL_RESULT], + }); + + // Regardless of whether it was shown, one result should have been added and + // its `payload.dupedHeuristic` should be set properly. + Assert.equal( + addedResults.length, + 1, + "The provider should have added one result" + ); + Assert.equal( + !addedResults[0].payload.dupedHeuristic, + expectBestMatch, + "dupedHeuristic should be the opposite of expectBestMatch" + ); + addedResults = []; + + await PlacesUtils.history.clear(); + } + + sandbox.restore(); +}); + +function makeExpectedResult({ + isBestMatch, + suggestedIndex, + dupedHeuristic, + telemetryType = "top_picks", +}) { + let result = { + isBestMatch, + suggestedIndex, + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + dupedHeuristic, + telemetryType, + title: "title", + url: SUGGESTION_URL, + displayUrl: SUGGESTION_URL_DISPLAY, + icon: "icon", + isSponsored: false, + shouldShowUrl: true, + source: "merino", + provider: telemetryType, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }, + }; + if (typeof dupedHeuristic == "boolean") { + result.payload.dupedHeuristic = dupedHeuristic; + } + return result; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js new file mode 100644 index 0000000000..aa9c700f1c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js @@ -0,0 +1,842 @@ +/* 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 Yelp suggestions. + +"use strict"; + +const { GEOLOCATION } = MerinoTestUtils; + +const REMOTE_SETTINGS_RECORDS = [ + { + type: "yelp-suggestions", + attachment: { + subjects: ["ramen", "ab", "alongerkeyword"], + preModifiers: ["best"], + postModifiers: ["delivery"], + locationSigns: [ + { keyword: "in", needLocation: true }, + { keyword: "nearby", needLocation: false }, + ], + yelpModifiers: [], + icon: "1234", + score: 0.5, + }, + }, +]; + +add_setup(async function () { + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: REMOTE_SETTINGS_RECORDS, + prefs: [ + ["quicksuggest.rustEnabled", true], + ["suggest.quicksuggest.sponsored", true], + ["suggest.yelp", true], + ["yelp.featureGate", true], + ["yelp.minKeywordLength", 5], + ], + }); + + await MerinoTestUtils.initGeolocation(); +}); + +add_task(async function basic() { + const TEST_DATA = [ + { + description: "Basic", + query: "best ramen delivery in tokyo", + expected: { + url: "https://www.yelp.com/search?find_desc=best+ramen+delivery&find_loc=tokyo", + title: "best ramen delivery in tokyo", + }, + }, + { + description: "With upper case", + query: "BeSt RaMeN dElIvErY iN tOkYo", + expected: { + url: "https://www.yelp.com/search?find_desc=BeSt+RaMeN+dElIvErY&find_loc=tOkYo", + title: "BeSt RaMeN dElIvErY iN tOkYo", + }, + }, + { + description: "No specific location with location-sign", + query: "ramen in", + expected: { + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen", + displayUrl: + "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa", + title: "ramen in Yokohama, Kanagawa", + }, + }, + { + description: "No specific location with location-modifier", + query: "ramen nearby", + expected: { + url: "https://www.yelp.com/search?find_desc=ramen+nearby&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen+nearby", + displayUrl: + "yelp.com/search?find_desc=ramen+nearby&find_loc=Yokohama,+Kanagawa", + title: "ramen nearby in Yokohama, Kanagawa", + }, + }, + { + description: "Query too short, no subject exact match: ra", + query: "ra", + expected: null, + }, + { + description: "Query too short, no subject not exact match: ram", + query: "ram", + expected: null, + }, + { + description: "Query too short, no subject exact match: rame", + query: "rame", + expected: null, + }, + { + description: + "Query length == minKeywordLength, subject exact match: ramen", + query: "ramen", + expected: { + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen", + displayUrl: + "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa", + title: "ramen in Yokohama, Kanagawa", + }, + }, + { + description: "Pre-modifier only", + query: "best", + expected: null, + }, + { + description: "Pre-modifier only with trailing space", + query: "best ", + expected: null, + }, + { + description: "Pre-modifier, subject too short", + query: "best r", + expected: null, + }, + { + description: "Pre-modifier, query long enough, subject long enough", + query: "best ra", + expected: { + url: "https://www.yelp.com/search?find_desc=best+ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=best+ramen", + displayUrl: + "yelp.com/search?find_desc=best+ramen&find_loc=Yokohama,+Kanagawa", + title: "best ramen in Yokohama, Kanagawa", + }, + }, + { + description: "Subject exact match with length < minKeywordLength", + query: "ab", + expected: { + url: "https://www.yelp.com/search?find_desc=ab&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ab", + displayUrl: "yelp.com/search?find_desc=ab&find_loc=Yokohama,+Kanagawa", + title: "ab in Yokohama, Kanagawa", + }, + }, + { + description: + "Subject exact match with length < minKeywordLength, showLessFrequentlyCount non-zero", + query: "ab", + showLessFrequentlyCount: 1, + expected: null, + }, + { + description: + "Subject exact match with length == minKeywordLength, showLessFrequentlyCount non-zero", + query: "ramen", + showLessFrequentlyCount: 1, + expected: { + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=ramen", + displayUrl: + "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa", + title: "ramen in Yokohama, Kanagawa", + }, + }, + { + description: "Query too short: alon", + query: "alon", + expected: null, + }, + { + description: "Query length == minKeywordLength, subject not exact match", + query: "along", + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length == minKeywordLength, subject not exact match, showLessFrequentlyCount non-zero", + query: "along", + showLessFrequentlyCount: 1, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length == minKeywordLength + showLessFrequentlyCount, subject not exact match", + query: "alonge", + showLessFrequentlyCount: 1, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length < minKeywordLength + showLessFrequentlyCount, subject not exact match", + query: "alonge", + showLessFrequentlyCount: 2, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + { + description: + "Query length == minKeywordLength + showLessFrequentlyCount, subject not exact match", + query: "alonger", + showLessFrequentlyCount: 2, + expected: { + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa", + originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword", + displayUrl: + "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa", + title: "alongerkeyword in Yokohama, Kanagawa", + }, + }, + ]; + + for (let { + description, + query, + showLessFrequentlyCount, + expected, + } of TEST_DATA) { + info( + "Doing basic subtest: " + + JSON.stringify({ + description, + query, + showLessFrequentlyCount, + expected, + }) + ); + + if (typeof showLessFrequentlyCount == "number") { + UrlbarPrefs.set("yelp.showLessFrequentlyCount", showLessFrequentlyCount); + } + + await check_results({ + context: createContext(query, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: expected ? [makeExpectedResult(expected)] : [], + }); + + UrlbarPrefs.clear("yelp.showLessFrequentlyCount"); + } +}); + +add_task(async function telemetryType() { + Assert.equal( + QuickSuggest.getFeature("YelpSuggestions").getSuggestionTelemetryType({}), + "yelp", + "Telemetry type should be 'yelp'" + ); +}); + +// When sponsored suggestions are disabled, Yelp suggestions should be +// disabled. +add_task(async function sponsoredDisabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + + // First make sure the suggestion is added when non-sponsored + // suggestions are enabled, if the rust is enabled. + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be disabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored"); + await QuickSuggestTestUtils.forceSync(); + + // Make sure Yelp is enabled again. + Assert.ok( + QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be re-enabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); +}); + +// When Yelp-specific preferences are disabled, suggestions should not be +// added. +add_task(async function yelpSpecificPrefsDisabled() { + const prefs = ["suggest.yelp", "yelp.featureGate"]; + for (const pref of prefs) { + // First make sure the suggestion is added, if the rust is enabled. + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + Assert.ok( + !QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be disabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.forceSync(); + + // Make sure Yelp is enabled again. + Assert.ok( + QuickSuggest.getFeature("YelpSuggestions").isEnabled, + "Yelp should be re-enabled" + ); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + } +}); + +// Check wheather the Yelp suggestions will be shown by the setup of Nimbus +// variable. +add_task(async function featureGate() { + // Disable the fature gate. + UrlbarPrefs.set("yelp.featureGate", false); + await check_results({ + context: createContext("ramem in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + yelpFeatureGate: true, + }); + await QuickSuggestTestUtils.forceSync(); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }), + ], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("yelp.featureGate", true); + await QuickSuggestTestUtils.forceSync(); + + // Disable by Nimbus. + const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({ + yelpFeatureGate: false, + }); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + await cleanUpNimbusDisable(); + + // Revert. + UrlbarPrefs.set("yelp.featureGate", true); + await QuickSuggestTestUtils.forceSync(); +}); + +// Check wheather the Yelp suggestions will be shown as top_pick by the Nimbus +// variable. +add_task(async function yelpSuggestPriority() { + // Enable by Nimbus. + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + yelpSuggestPriority: true, + }); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: true, + }), + ], + }); + + await cleanUpNimbusEnable(); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: false, + }), + ], + }); +}); + +// Tests the `yelpSuggestNonPriorityIndex` Nimbus variable, which controls the +// group-relative suggestedIndex. The default Yelp suggestedIndex is 0, unlike +// most other Suggest suggestion types, which use -1. +add_task(async function nimbusSuggestedIndex() { + const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({ + yelpSuggestNonPriorityIndex: -1, + }); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: false, + suggestedIndex: -1, + }), + ], + }); + + await cleanUpNimbusEnable(); + await QuickSuggestTestUtils.forceSync(); + + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + isTopPick: false, + suggestedIndex: 0, + }), + ], + }); +}); + +// Tests the "Not relevant" command: a dismissed suggestion shouldn't be added. +add_task(async function notRelevant() { + let result = makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }); + + info("Triggering the 'Not relevant' command"); + QuickSuggest.getFeature("YelpSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_relevant" + ); + await QuickSuggest.blockedSuggestions._test_readyPromise; + + Assert.ok( + await QuickSuggest.blockedSuggestions.has(result.payload.originalUrl), + "The result's URL should be blocked" + ); + + info("Doing search for blocked suggestion"); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for a suggestion that wasn't blocked"); + await check_results({ + context: createContext("alongerkeyword in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=tokyo", + title: "alongerkeyword in tokyo", + }), + ], + }); + + info("Clearing blocked suggestions"); + await QuickSuggest.blockedSuggestions.clear(); + + info("Doing search for unblocked suggestion"); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); +}); + +// Tests the "Not interested" command: all Yelp suggestions should be disabled +// and not added anymore. +add_task(async function notInterested() { + let result = makeExpectedResult({ + url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + title: "ramen in tokyo", + }); + + info("Triggering the 'Not interested' command"); + QuickSuggest.getFeature("YelpSuggestions").handleCommand( + { + controller: { removeResult() {} }, + }, + result, + "not_interested" + ); + + Assert.ok( + !UrlbarPrefs.get("suggest.yelp"), + "Yelp suggestions should be disabled" + ); + + info("Doing search for the suggestion the command was used on"); + await check_results({ + context: createContext("ramen in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + info("Doing search for another Yelp suggestion"); + await check_results({ + context: createContext("alongerkeyword in tokyo", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + UrlbarPrefs.clear("suggest.yelp"); + await QuickSuggestTestUtils.forceSync(); +}); + +// Tests the "show less frequently" behavior. +add_task(async function showLessFrequently() { + UrlbarPrefs.set("yelp.showLessFrequentlyCount", 0); + UrlbarPrefs.set("yelp.minKeywordLength", 0); + let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({ + yelpMinKeywordLength: 0, + yelpShowLessFrequentlyCap: 3, + }); + + let location = `${GEOLOCATION.city}, ${GEOLOCATION.region}`; + + let originalUrl = new URL("https://www.yelp.com/search"); + originalUrl.searchParams.set("find_desc", "best ramen"); + + let url = new URL(originalUrl); + url.searchParams.set("find_loc", location); + + let result = makeExpectedResult({ + url: url.toString(), + originalUrl: originalUrl.toString(), + title: `best ramen in ${location}`, + }); + + const testData = [ + { + input: "best ra", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 0, + minKeywordLength: 0, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 8, + }, + }, + { + input: "best ram", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 1, + minKeywordLength: 8, + }, + after: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 9, + }, + }, + { + input: "best rame", + before: { + canShowLessFrequently: true, + showLessFrequentlyCount: 2, + minKeywordLength: 9, + }, + after: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 10, + }, + }, + { + input: "best ramen", + before: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 10, + }, + after: { + canShowLessFrequently: false, + showLessFrequentlyCount: 3, + minKeywordLength: 11, + }, + }, + ]; + + for (let { input, before, after } of testData) { + let feature = QuickSuggest.getFeature("YelpSuggestions"); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [result], + }); + + Assert.equal( + UrlbarPrefs.get("yelp.minKeywordLength"), + before.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, before.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + before.showLessFrequentlyCount + ); + + feature.handleCommand( + { + acknowledgeFeedback: () => {}, + invalidateResultMenuCommands: () => {}, + }, + result, + "show_less_frequently", + input + ); + + Assert.equal( + UrlbarPrefs.get("yelp.minKeywordLength"), + after.minKeywordLength + ); + Assert.equal(feature.canShowLessFrequently, after.canShowLessFrequently); + Assert.equal( + feature.showLessFrequentlyCount, + after.showLessFrequentlyCount + ); + + await check_results({ + context: createContext(input, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + } + + await cleanUpNimbus(); + UrlbarPrefs.clear("yelp.showLessFrequentlyCount"); + UrlbarPrefs.clear("yelp.minKeywordLength"); +}); + +// The `Yelp` Rust provider should be passed to the Rust component when +// querying depending on whether Yelp suggestions are enabled. +add_task(async function rustProviders() { + await doRustProvidersTests({ + searchString: "ramen in tokyo", + tests: [ + { + prefs: { + "suggest.yelp": true, + }, + expectedUrls: [ + "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo", + ], + }, + { + prefs: { + "suggest.yelp": false, + }, + expectedUrls: [], + }, + ], + }); + + UrlbarPrefs.clear("suggest.yelp"); + await QuickSuggestTestUtils.forceSync(); +}); + +function makeExpectedResult({ + url, + title, + isTopPick = false, + // The default Yelp suggestedIndex is 0, unlike most other Suggest suggestion + // types, which use -1. + suggestedIndex = 0, + isSuggestedIndexRelativeToGroup = true, + originalUrl = undefined, + displayUrl = undefined, +}) { + const utmParameters = "&utm_medium=partner&utm_source=mozilla"; + + originalUrl ??= url; + + displayUrl = + (displayUrl ?? + url + .replace(/^https:\/\/www[.]/, "") + .replace("%20", " ") + .replace("%2C", ",")) + utmParameters; + + url += utmParameters; + + if (isTopPick) { + suggestedIndex = 1; + isSuggestedIndexRelativeToGroup = false; + } + + return { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + isBestMatch: !!isTopPick, + suggestedIndex, + isSuggestedIndexRelativeToGroup, + heuristic: false, + payload: { + source: "rust", + provider: "Yelp", + telemetryType: "yelp", + shouldShowUrl: true, + bottomTextL10n: { id: "firefox-suggest-yelp-bottom-text" }, + url, + originalUrl, + title, + displayUrl, + icon: null, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js b/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js new file mode 100644 index 0000000000..e6ec61bcd4 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js @@ -0,0 +1,244 @@ +/* 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 ingest in the Rust backend. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +// These consts are copied from the update timer manager test. See +// `initUpdateTimerManager()`. +const PREF_APP_UPDATE_TIMERMINIMUMDELAY = "app.update.timerMinimumDelay"; +const PREF_APP_UPDATE_TIMERFIRSTINTERVAL = "app.update.timerFirstInterval"; +const MAIN_TIMER_INTERVAL = 1000; // milliseconds +const CATEGORY_UPDATE_TIMER = "update-timer"; + +const REMOTE_SETTINGS_SUGGESTION = { + id: 1, + url: "http://example.com/amp", + title: "AMP Suggestion", + keywords: ["amp"], + click_url: "http://example.com/amp-click", + impression_url: "http://example.com/amp-impression", + advertiser: "Amp", + iab_category: "22 - Shopping", + icon: "1234", +}; + +add_setup(async function () { + initUpdateTimerManager(); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "data", + attachment: [REMOTE_SETTINGS_SUGGESTION], + }, + ], + prefs: [ + ["suggest.quicksuggest.sponsored", true], + ["quicksuggest.rustEnabled", false], + ], + }); +}); + +// IMPORTANT: This task must run first! +// +// This simulates the first time the Rust backend is enabled in a profile. The +// backend should perform ingestion immediately. +add_task(async function firstRun() { + Assert.ok( + !UrlbarPrefs.get("quicksuggest.rustEnabled"), + "rustEnabled pref is initially false (this task must run first!)" + ); + Assert.strictEqual( + QuickSuggest.rustBackend.isEnabled, + false, + "Rust backend is initially disabled (this task must run first!)" + ); + Assert.ok( + !QuickSuggest.rustBackend.ingestPromise, + "No ingest has been performed yet (this task must run first!)" + ); + + info("Enabling the Rust backend"); + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + Assert.ok(QuickSuggest.rustBackend.isEnabled, "Rust backend is now enabled"); + + // An ingest should start. + let { ingestPromise } = await waitForIngestStart(null); + + info("Awaiting ingest promise"); + await ingestPromise; + info("Done awaiting ingest promise"); + + await checkSuggestions(); + + // Disable and re-enable the backend. No new ingestion should start + // immediately since this isn't the first time the backend has been enabled. + UrlbarPrefs.set("quicksuggest.rustEnabled", false); + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + await assertNoNewIngestStarted(ingestPromise); + + await checkSuggestions(); + + UrlbarPrefs.set("quicksuggest.rustEnabled", false); +}); + +// Ingestion should be performed according to the defined interval. +add_task(async function interval() { + let { ingestPromise } = QuickSuggest.rustBackend; + Assert.ok( + ingestPromise, + "Sanity check: An ingest has already been performed" + ); + Assert.ok( + !UrlbarPrefs.get("quicksuggest.rustEnabled"), + "Sanity check: Rust backend is initially disabled" + ); + + // Set a small interval and enable the backend. No new ingestion should start + // immediately since this isn't the first time the backend has been enabled. + let intervalSecs = 1; + UrlbarPrefs.set("quicksuggest.rustIngestIntervalSeconds", intervalSecs); + UrlbarPrefs.set("quicksuggest.rustEnabled", true); + await assertNoNewIngestStarted(ingestPromise); + + // Wait for a few ingests to happen. + for (let i = 0; i < 3; i++) { + info("Preparing for ingest at index " + i); + + // Set a new suggestion so we can make sure ingest really happened. + let suggestion = { + ...REMOTE_SETTINGS_SUGGESTION, + url: REMOTE_SETTINGS_SUGGESTION.url + "/" + i, + }; + await QuickSuggestTestUtils.setRemoteSettingsRecords( + [ + { + type: "data", + attachment: [suggestion], + }, + ], + // Don't force sync since the whole point here is to make sure the backend + // ingests on its own! + { forceSync: false } + ); + + // Wait for ingest to start and finish. + info("Waiting for ingest to start at index " + i); + ({ ingestPromise } = await waitForIngestStart(ingestPromise)); + info("Waiting for ingest to finish at index " + i); + await ingestPromise; + await checkSuggestions([suggestion]); + } + + // In the loop above, there was one additional async call after awaiting the + // ingest promise, to `checkSuggestions()`. It's possible, though unlikely, + // that call took so long that another ingest has started. To be sure, wait + // for one final ingest to start before continuing. + ({ ingestPromise } = await waitForIngestStart(ingestPromise)); + + // Now immediately disable the backend. New ingests should not start, but the + // final one will still be ongoing. + info("Disabling the backend"); + UrlbarPrefs.set("quicksuggest.rustEnabled", false); + + info("Awaiting final ingest promise"); + await ingestPromise; + + // Wait a few seconds. + let waitSecs = 3 * intervalSecs; + info(`Waiting ${waitSecs}s...`); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(r => setTimeout(r, 1000 * waitSecs)); + + // No new ingests should have started. + Assert.equal( + QuickSuggest.rustBackend.ingestPromise, + ingestPromise, + "No new ingest started after disabling the backend" + ); + + UrlbarPrefs.clear("quicksuggest.rustIngestIntervalSeconds"); +}); + +async function waitForIngestStart(oldIngestPromise) { + let newIngestPromise; + await TestUtils.waitForCondition(() => { + let { ingestPromise } = QuickSuggest.rustBackend; + if ( + (oldIngestPromise && ingestPromise != oldIngestPromise) || + (!oldIngestPromise && ingestPromise) + ) { + newIngestPromise = ingestPromise; + return true; + } + return false; + }, "Waiting for a new ingest to start"); + + Assert.equal( + QuickSuggest.rustBackend.ingestPromise, + newIngestPromise, + "Sanity check: ingestPromise hasn't changed since waitForCondition returned" + ); + + // A bare promise can't be returned because it will cause the awaiting caller + // to await that promise! We're simply trying to return the promise, which the + // caller can later await. + return { ingestPromise: newIngestPromise }; +} + +async function assertNoNewIngestStarted(oldIngestPromise) { + for (let i = 0; i < 3; i++) { + await TestUtils.waitForTick(); + } + Assert.equal( + QuickSuggest.rustBackend.ingestPromise, + oldIngestPromise, + "No new ingest started" + ); +} + +async function checkSuggestions(expected = [REMOTE_SETTINGS_SUGGESTION]) { + let actual = await QuickSuggest.rustBackend.query("amp"); + Assert.deepEqual( + actual.map(s => s.url), + expected.map(s => s.url), + "Backend should be serving the expected suggestions" + ); +} + +/** + * Sets up the update timer manager for testing: makes it fire more often, + * removes all existing timers, and initializes it for testing. The body of this + * function is copied from: + * https://searchfox.org/mozilla-central/source/toolkit/components/timermanager/tests/unit/consumerNotifications.js + */ +function initUpdateTimerManager() { + // Set the timer to fire every second + Services.prefs.setIntPref( + PREF_APP_UPDATE_TIMERMINIMUMDELAY, + MAIN_TIMER_INTERVAL / 1000 + ); + Services.prefs.setIntPref( + PREF_APP_UPDATE_TIMERFIRSTINTERVAL, + MAIN_TIMER_INTERVAL + ); + + // Remove existing update timers to prevent them from being notified + for (let { data: entry } of Services.catMan.enumerateCategory( + CATEGORY_UPDATE_TIMER + )) { + Services.catMan.deleteCategoryEntry(CATEGORY_UPDATE_TIMER, entry, false); + } + + Cc["@mozilla.org/updates/timer-manager;1"] + .getService(Ci.nsIUpdateTimerManager) + .QueryInterface(Ci.nsIObserver) + .observe(null, "utm-test-init", ""); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js new file mode 100644 index 0000000000..f50fe32dd3 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js @@ -0,0 +1,293 @@ +/* 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 `SuggestionsMap`. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", + SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs", +}); + +// This overrides `SuggestionsMap.chunkSize`. Testing the actual value can make +// the test run too long. This is OK because the correctness of the chunking +// behavior doesn't depend on the chunk size. +const TEST_CHUNK_SIZE = 100; + +add_setup(async () => { + // Sanity check the actual `chunkSize` value. + Assert.equal( + typeof SuggestionsMap.chunkSize, + "number", + "Sanity check: chunkSize is a number" + ); + Assert.greater(SuggestionsMap.chunkSize, 0, "Sanity check: chunkSize > 0"); + + // Set our test value. + SuggestionsMap.chunkSize = TEST_CHUNK_SIZE; +}); + +// Tests many suggestions with one keyword each. +add_task(async function chunking_singleKeyword() { + let suggestionCounts = [ + 1 * SuggestionsMap.chunkSize - 1, + 1 * SuggestionsMap.chunkSize, + 1 * SuggestionsMap.chunkSize + 1, + 2 * SuggestionsMap.chunkSize - 1, + 2 * SuggestionsMap.chunkSize, + 2 * SuggestionsMap.chunkSize + 1, + 3 * SuggestionsMap.chunkSize - 1, + 3 * SuggestionsMap.chunkSize, + 3 * SuggestionsMap.chunkSize + 1, + ]; + for (let count of suggestionCounts) { + await doChunkingTest(count, 1); + } +}); + +// Tests a small number of suggestions with many keywords each. +add_task(async function chunking_manyKeywords() { + let keywordCounts = [ + 1 * SuggestionsMap.chunkSize - 1, + 1 * SuggestionsMap.chunkSize, + 1 * SuggestionsMap.chunkSize + 1, + 2 * SuggestionsMap.chunkSize - 1, + 2 * SuggestionsMap.chunkSize, + 2 * SuggestionsMap.chunkSize + 1, + 3 * SuggestionsMap.chunkSize - 1, + 3 * SuggestionsMap.chunkSize, + 3 * SuggestionsMap.chunkSize + 1, + ]; + for (let suggestionCount = 1; suggestionCount <= 3; suggestionCount++) { + for (let keywordCount of keywordCounts) { + await doChunkingTest(suggestionCount, keywordCount); + } + } +}); + +async function doChunkingTest(suggestionCount, keywordCountPerSuggestion) { + info( + "Running chunking test: " + + JSON.stringify({ suggestionCount, keywordCountPerSuggestion }) + ); + + // Create `suggestionCount` suggestions, each with `keywordCountPerSuggestion` + // keywords. + let suggestions = []; + for (let i = 0; i < suggestionCount; i++) { + let keywords = []; + for (let k = 0; k < keywordCountPerSuggestion; k++) { + keywords.push(`keyword-${i}-${k}`); + } + suggestions.push({ + keywords, + id: i, + url: "http://example.com/" + i, + title: "Suggestion " + i, + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }); + } + + // Add the suggestions. + let map = new SuggestionsMap(); + await map.add(suggestions); + + // Make sure all keyword-suggestion pairs have been added. + for (let i = 0; i < suggestionCount; i++) { + for (let k = 0; k < keywordCountPerSuggestion; k++) { + let keyword = `keyword-${i}-${k}`; + + // Check the map. Logging all assertions takes a ton of time and makes the + // test run much longer than it otherwise would, especially if `chunkSize` + // is large, so only log failing assertions. + let actualSuggestions = map.get(keyword); + if (!ObjectUtils.deepEqual(actualSuggestions, [suggestions[i]])) { + Assert.deepEqual( + actualSuggestions, + [suggestions[i]], + `Suggestion ${i} is present for keyword ${keyword}` + ); + } + } + } +} + +add_task(async function duplicateKeywords() { + let suggestions = [ + { + title: "suggestion 0", + keywords: ["a", "a", "a", "b", "b", "c"], + }, + { + title: "suggestion 1", + keywords: ["b", "c", "d"], + }, + { + title: "suggestion 2", + keywords: ["c", "d", "e"], + }, + { + title: "suggestion 3", + keywords: ["f", "f"], + }, + ]; + + let expectedIndexesByKeyword = { + a: [0], + b: [0, 1], + c: [0, 1, 2], + d: [1, 2], + e: [2], + f: [3], + }; + + let map = new SuggestionsMap(); + await map.add(suggestions); + + for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) { + Assert.deepEqual( + map.get(keyword), + indexes.map(i => suggestions[i]), + "get() with keyword: " + keyword + ); + } +}); + +add_task(async function mapKeywords() { + let suggestions = [ + { + title: "suggestion 0", + keywords: ["a", "a", "a", "b", "b", "c"], + }, + { + title: "suggestion 1", + keywords: ["b", "c", "d"], + }, + { + title: "suggestion 2", + keywords: ["c", "d", "e"], + }, + { + title: "suggestion 3", + keywords: ["f", "f"], + }, + ]; + + let expectedIndexesByKeyword = { + a: [], + b: [], + c: [], + d: [], + e: [], + f: [], + ax: [0], + bx: [0, 1], + cx: [0, 1, 2], + dx: [1, 2], + ex: [2], + fx: [3], + fy: [3], + fz: [3], + }; + + let map = new SuggestionsMap(); + await map.add(suggestions, { + mapKeyword: keyword => { + if (keyword == "f") { + return [keyword + "x", keyword + "y", keyword + "z"]; + } + return [keyword + "x"]; + }, + }); + + for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) { + Assert.deepEqual( + map.get(keyword), + indexes.map(i => suggestions[i]), + "get() with keyword: " + keyword + ); + } +}); + +// Tests `keywordsProperty`. +add_task(async function keywordsProperty() { + let suggestion = { + title: "suggestion", + keywords: ["should be ignored"], + foo: ["hello"], + }; + + let map = new SuggestionsMap(); + await map.add([suggestion], { + keywordsProperty: "foo", + }); + + Assert.deepEqual( + map.get("hello"), + [suggestion], + "Keyword in `foo` should match" + ); + Assert.deepEqual( + map.get("should be ignored"), + [], + "Keyword in `keywords` should not match" + ); +}); + +// Tests `MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD`. +add_task(async function prefixesStartingAtFirstWord() { + let suggestion = { + title: "suggestion", + keywords: ["one two three", "four five six"], + }; + + // keyword passed to `get()` -> should match + let tests = { + o: false, + on: false, + one: true, + "one ": true, + "one t": true, + "one tw": true, + "one two": true, + "one two ": true, + "one two t": true, + "one two th": true, + "one two thr": true, + "one two thre": true, + "one two three": true, + "one two three ": false, + f: false, + fo: false, + fou: false, + four: true, + "four ": true, + "four f": true, + "four fi": true, + "four fiv": true, + "four five": true, + "four five ": true, + "four five s": true, + "four five si": true, + "four five six": true, + "four five six ": false, + }; + + let map = new SuggestionsMap(); + await map.add([suggestion], { + mapKeyword: SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD, + }); + + for (let [keyword, shouldMatch] of Object.entries(tests)) { + Assert.deepEqual( + map.get(keyword), + shouldMatch ? [suggestion] : [], + "get() with keyword: " + keyword + ); + } +}); diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js new file mode 100644 index 0000000000..28801904a1 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js @@ -0,0 +1,1402 @@ +/* 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 quick suggest weather feature. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs", + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS"; +const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER"; + +const { WEATHER_RS_DATA, WEATHER_SUGGESTION } = MerinoTestUtils; + +add_setup(async () => { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + prefs: [["suggest.quicksuggest.nonsponsored", true]], + remoteSettingsRecords: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + + await MerinoTestUtils.initWeather(); + + // Give this a small value so it doesn't delay the test too long. Choose a + // value that's unlikely to be used anywhere else in the test so that when + // `lastFetchTimeMs` is expected to be `fetchDelayAfterComingOnlineMs`, we can + // be sure the value actually came from `fetchDelayAfterComingOnlineMs`. + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs = 53; +}); + +// The feature should be properly uninitialized when it's disabled and then +// re-initialized when it's re-enabled. This task disables the feature using the +// feature gate pref. +add_tasks_with_rust(async function disableAndEnable_featureGate() { + await doBasicDisableAndEnableTest("weather.featureGate"); +}); + +// The feature should be properly uninitialized when it's disabled and then +// re-initialized when it's re-enabled. This task disables the feature using the +// suggest pref. +add_tasks_with_rust(async function disableAndEnable_suggestPref() { + await doBasicDisableAndEnableTest("suggest.weather"); +}); + +async function doBasicDisableAndEnableTest(pref) { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set(pref, false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // No suggestion should be returned for a search. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + info("Re-enable the feature"); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set(pref, true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + await fetchPromise; + assertEnabled({ + message: "After awaiting fetch", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "success", + "The request successfully finished" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "success", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); + + // The suggestion should be returned for a search. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeWeatherResult()], + }); +} + +// This task is only appropriate for the JS backend, not Rust, since fetching is +// always active with Rust. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function keywordsNotDefined() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Set RS data without any keywords. Fetching should immediately stop. + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: {}, + }, + ]); + assertDisabled({ + message: "After setting RS data without keywords", + pendingFetchCount: 0, + }); + + // No suggestion should be returned for a search. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Set keywords. Fetching should immediately start. + info("Setting keywords"); + let fetchPromise = QuickSuggest.weather.waitForFetches(); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + assertEnabled({ + message: "Immediately after setting keywords", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + await fetchPromise; + assertEnabled({ + message: "After awaiting fetch", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "success", + "The request successfully finished" + ); + + // The suggestion should be returned for a search. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeWeatherResult()], + }); + } +); + +// Disables and re-enables the feature without waiting for any intermediate +// fetches to complete, using the following steps: +// +// 1. Disable +// 2. Enable +// 3. Disable again +// +// At this point, the fetch from step 2 will remain ongoing but once it finishes +// it should be discarded since the feature is disabled. +add_tasks_with_rust(async function disableAndEnable_immediate1() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + // Disable it again. The fetch will remain ongoing since pending fetches + // aren't stopped when the feature is disabled. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling again", + pendingFetchCount: 1, + }); + + // Wait for the fetch to finish. + await fetchPromise; + + // The fetched suggestion should be discarded and the feature should remain + // uninitialized. + assertDisabled({ + message: "After awaiting fetch", + pendingFetchCount: 0, + }); + + // Clean up by re-enabling the feature for the remaining tasks. + fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + await fetchPromise; + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); + + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Disables and re-enables the feature without waiting for any intermediate +// fetches to complete, using the following steps: +// +// 1. Disable +// 2. Enable +// 3. Disable again +// 4. Enable again +// +// At this point, the fetches from steps 2 and 4 will remain ongoing. The fetch +// from step 2 should be discarded. +add_tasks_with_rust(async function disableAndEnable_immediate2() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Disable the feature. It should be immediately uninitialized. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling", + pendingFetchCount: 0, + }); + + // Re-enable the feature. It should be immediately initialized and a fetch + // should start. + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling", + hasSuggestion: false, + pendingFetchCount: 1, + }); + + // Disable it again. The fetch will remain ongoing since pending fetches + // aren't stopped when the feature is disabled. + UrlbarPrefs.set("weather.featureGate", false); + assertDisabled({ + message: "After disabling again", + pendingFetchCount: 1, + }); + + // Re-enable it. A new fetch should start, so now there will be two pending + // fetches. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("weather.featureGate", true); + assertEnabled({ + message: "Immediately after re-enabling again", + hasSuggestion: false, + pendingFetchCount: 2, + }); + + // Wait for both fetches to finish. + await fetchPromise; + assertEnabled({ + message: "Immediately after re-enabling again", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); +}); + +// A fetch that doesn't return a suggestion should cause the last-fetched +// suggestion to be discarded. +add_tasks_with_rust(async function noSuggestion() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + let { suggestions } = MerinoTestUtils.server.response.body; + MerinoTestUtils.server.response.body.suggestions = []; + + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "no_suggestion", + "The request successfully finished without suggestions" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "no_suggestion", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + MerinoTestUtils.server.response.body.suggestions = suggestions; + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// A network error should cause the last-fetched suggestion to be discarded. +add_tasks_with_rust(async function networkError() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Set the weather fetch timeout high enough that the network error exception + // will happen first. See `MerinoTestUtils.withNetworkError()`. + QuickSuggest.weather._test_setTimeoutMs(10000); + + await MerinoTestUtils.server.withNetworkError(async () => { + await QuickSuggest.weather._test_fetch(); + }); + + QuickSuggest.weather._test_setTimeoutMs(-1); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "network_error", + "The request failed with a network error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "network_error", + latencyRecorded: false, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// An HTTP error should cause the last-fetched suggestion to be discarded. +add_tasks_with_rust(async function httpError() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + MerinoTestUtils.server.response = { status: 500 }; + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "http_error", + "The request failed with an HTTP error" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "http_error", + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + MerinoTestUtils.server.reset(); + MerinoTestUtils.server.response.body.suggestions = [WEATHER_SUGGESTION]; + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// A fetch that doesn't return a suggestion due to a client timeout should cause +// the last-fetched suggestion to be discarded. +add_tasks_with_rust(async function clientTimeout() { + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let histograms = MerinoTestUtils.getAndClearHistograms({ + extraLatency: HISTOGRAM_LATENCY, + extraResponse: HISTOGRAM_RESPONSE, + }); + + // Make the server return a delayed response so the Merino client times out + // waiting for it. + MerinoTestUtils.server.response.delay = 400; + + // Make the client time out immediately. + QuickSuggest.weather._test_setTimeoutMs(1); + + // Set up a promise that will be resolved when the client finally receives the + // response. + let responsePromise = QuickSuggest.weather._test_merino.waitForNextResponse(); + + await QuickSuggest.weather._test_fetch(); + + assertEnabled({ + message: "After fetch", + hasSuggestion: false, + pendingFetchCount: 0, + }); + Assert.equal( + QuickSuggest.weather._test_merino.lastFetchStatus, + "timeout", + "The request timed out" + ); + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: "timeout", + latencyRecorded: false, + latencyStopwatchRunning: true, + client: QuickSuggest.weather._test_merino, + }); + + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Await the response. + await responsePromise; + + // The `checkAndClearHistograms()` call above cleared the histograms. After + // that, nothing else should have been recorded for the response. + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: true, + client: QuickSuggest.weather._test_merino, + }); + + QuickSuggest.weather._test_setTimeoutMs(-1); + delete MerinoTestUtils.server.response.delay; + + // Clean up by forcing another fetch so the suggestion is non-null for the + // remaining tasks. + await QuickSuggest.weather._test_fetch(); + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Locale task for when this test runs on an en-US OS. +add_tasks_with_rust(async function locale_enUS() { + await doLocaleTest({ + shouldRunTask: osLocale => osLocale == "en-US", + osUnit: "f", + unitsByLocale: { + "en-US": "f", + // When the app's locale is set to any en-* locale, F will be used because + // `regionalPrefsLocales` will prefer the en-US OS locale. + "en-CA": "f", + "en-GB": "f", + de: "c", + }, + }); +}); + +// Locale task for when this test runs on a non-US English OS. +add_tasks_with_rust(async function locale_nonUSEnglish() { + await doLocaleTest({ + shouldRunTask: osLocale => osLocale.startsWith("en") && osLocale != "en-US", + osUnit: "c", + unitsByLocale: { + // When the app's locale is set to en-US, C will be used because + // `regionalPrefsLocales` will prefer the non-US English OS locale. + "en-US": "c", + "en-CA": "c", + "en-GB": "c", + de: "c", + }, + }); +}); + +// Locale task for when this test runs on a non-English OS. +add_tasks_with_rust(async function locale_nonEnglish() { + await doLocaleTest({ + shouldRunTask: osLocale => !osLocale.startsWith("en"), + osUnit: "c", + unitsByLocale: { + "en-US": "f", + "en-CA": "c", + "en-GB": "c", + de: "c", + }, + }); +}); + +/** + * Testing locales is tricky due to the weather feature's use of + * `Services.locale.regionalPrefsLocales`. By default `regionalPrefsLocales` + * prefers the OS locale if its language is the same as the app locale's + * language; otherwise it prefers the app locale. For example, assuming the OS + * locale is en-CA, then if the app locale is en-US it will prefer en-CA since + * both are English, but if the app locale is de it will prefer de. If the pref + * `intl.regional_prefs.use_os_locales` is set, then the OS locale is always + * preferred. + * + * This function tests a given set of locales with and without + * `intl.regional_prefs.use_os_locales` set. + * + * @param {object} options + * Options + * @param {Function} options.shouldRunTask + * Called with the OS locale. Should return true if the function should run. + * Use this to skip tasks that don't target a desired OS locale. + * @param {string} options.osUnit + * The expected "c" or "f" unit for the OS locale. + * @param {object} options.unitsByLocale + * The expected "c" or "f" unit when the app's locale is set to particular + * locales. This should be an object that maps locales to expected units. For + * each locale in the object, the app's locale is set to that locale and the + * actual unit is expected to be the unit in the object. + */ +async function doLocaleTest({ shouldRunTask, osUnit, unitsByLocale }) { + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true); + let osLocale = Services.locale.regionalPrefsLocales[0]; + Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales"); + + if (!shouldRunTask(osLocale)) { + info("Skipping task, should not run for this OS locale"); + return; + } + + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Sanity check initial locale info. + Assert.equal( + Services.locale.appLocaleAsBCP47, + "en-US", + "Initial app locale should be en-US" + ); + Assert.ok( + !Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales"), + "intl.regional_prefs.use_os_locales should be false initially" + ); + + // Check locales. + for (let [locale, temperatureUnit] of Object.entries(unitsByLocale)) { + await QuickSuggestTestUtils.withLocales([locale], async () => { + info("Checking locale: " + locale); + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: [makeWeatherResult({ temperatureUnit })], + }); + + info( + "Checking locale with intl.regional_prefs.use_os_locales: " + locale + ); + Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true); + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: [makeWeatherResult({ temperatureUnit: osUnit })], + }); + Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales"); + }); + } +} + +// Blocks a result and makes sure the weather pref is disabled. +add_tasks_with_rust(async function block() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + Assert.ok( + UrlbarPrefs.get("suggest.weather"), + "Sanity check: suggest.weather is true initially" + ); + + // Do a search so we can get an actual result. + let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeWeatherResult()], + }); + + // Block the result. + const controller = UrlbarTestUtils.newMockController(); + controller.setView({ + get visibleResults() { + return context.results; + }, + controller: { + removeResult() {}, + }, + }); + let result = context.results[0]; + let provider = UrlbarProvidersManager.getProvider(result.providerName); + Assert.ok(provider, "Sanity check: Result provider found"); + provider.onEngagement( + "engagement", + context, + { + result, + selType: "dismiss", + selIndex: context.results[0].rowIndex, + }, + controller + ); + Assert.ok( + !UrlbarPrefs.get("suggest.weather"), + "suggest.weather is false after blocking the result" + ); + + // Do a second search. Nothing should be returned. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [], + }); + + // Re-enable the pref and clean up. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + UrlbarPrefs.set("suggest.weather", true); + await fetchPromise; + + // Wait for keywords to be re-synced from remote settings. + await QuickSuggestTestUtils.forceSync(); + + assertEnabled({ + message: "On cleanup", + hasSuggestion: true, + pendingFetchCount: 0, + }); +}); + +// Simulates wake 100ms before the start of the next fetch period. A new fetch +// should not start. +add_tasks_with_rust(async function wakeBeforeNextFetchPeriod() { + await doWakeTest({ + sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs - 100, + shouldFetchOnWake: false, + fetchTimerMsOnWake: 100, + }); +}); + +// Simulates wake 100ms after the start of the next fetch period. A new fetch +// should start. +add_tasks_with_rust(async function wakeAfterNextFetchPeriod() { + await doWakeTest({ + sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs + 100, + shouldFetchOnWake: true, + }); +}); + +// Simulates wake after many fetch periods + 100ms. A new fetch should start. +add_tasks_with_rust(async function wakeAfterManyFetchPeriods() { + await doWakeTest({ + sleepIntervalMs: 100 * QuickSuggest.weather._test_fetchIntervalMs + 100, + shouldFetchOnWake: true, + }); +}); + +async function doWakeTest({ + sleepIntervalMs, + shouldFetchOnWake, + fetchTimerMsOnWake, +}) { + // Make `Date.now()` return a value under our control, doesn't matter what it + // is. This is the time the first fetch period will start. + let nowOnStart = 100; + let sandbox = sinon.createSandbox(); + let dateNowStub = sandbox.stub( + Cu.getGlobalForObject(QuickSuggest.weather).Date, + "now" + ); + dateNowStub.returns(nowOnStart); + + // Start the first fetch period. + info("Starting first fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "Last fetch time should be updated after fetch" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + let timer = QuickSuggest.weather._test_fetchTimer; + + // Advance the clock and simulate wake. + info("Sending wake notification"); + let nowOnWake = nowOnStart + sleepIntervalMs; + dateNowStub.returns(nowOnWake); + QuickSuggest.weather.observe(null, "wake_notification", ""); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "After wake, next fetch should not have immediately started" + ); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "After wake, last fetch time should be unchanged" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "After wake, the timer should exist (be non-zero)" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "After wake, a new timer should have been created" + ); + + if (shouldFetchOnWake) { + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs, + "After wake, timer period should be fetchDelayAfterComingOnlineMs" + ); + } else { + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + fetchTimerMsOnWake, + "After wake, timer period should be the remaining interval" + ); + } + + // Wait for the fetch. If the wake didn't trigger it, then the caller should + // have passed in a `sleepIntervalMs` that will make it start soon. + info("Waiting for fetch after wake"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "After post-wake fetch, timer period should remain full fetch interval" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "After post-wake fetch, no more fetches should be pending" + ); + + dateNowStub.restore(); +} + +// When network:link-status-changed is observed and the suggestion is non-null, +// a fetch should not start. +add_tasks_with_rust(async function networkLinkStatusChanged_nonNull() { + // See nsINetworkLinkService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "network:link-status-changed", + dataValues: [ + "down", + "up", + "changed", + "unknown", + "this is not a valid data value", + ], + }); +}); + +// When network:offline-status-changed is observed and the suggestion is +// non-null, a fetch should not start. +add_tasks_with_rust(async function networkOfflineStatusChanged_nonNull() { + // See nsIIOService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "network:offline-status-changed", + dataValues: ["offline", "online", "this is not a valid data value"], + }); +}); + +// When captive-portal-login-success is observed and the suggestion is non-null, +// a fetch should not start. +add_tasks_with_rust(async function captivePortalLoginSuccess_nonNull() { + // See nsIIOService for possible data values. + await doOnlineTestWithSuggestion({ + topic: "captive-portal-login-success", + dataValues: [""], + }); +}); + +async function doOnlineTestWithSuggestion({ topic, dataValues }) { + info("Starting fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.ok( + QuickSuggest.weather.suggestion, + "Suggestion should have been fetched" + ); + + let timer = QuickSuggest.weather._test_fetchTimer; + + for (let data of dataValues) { + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimer, + timer, + "Timer should not have been recreated" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be the full fetch interval" + ); + } +} + +// When network:link-status-changed is observed and the suggestion is null, a +// fetch should start unless the data indicates the status is offline. +add_tasks_with_rust(async function networkLinkStatusChanged_null() { + // See nsINetworkLinkService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "network:link-status-changed", + offlineData: "down", + otherDataValues: [ + "up", + "changed", + "unknown", + "this is not a valid data value", + ], + }); +}); + +// When network:offline-status-changed is observed and the suggestion is null, a +// fetch should start unless the data indicates the status is offline. +add_tasks_with_rust(async function networkOfflineStatusChanged_null() { + // See nsIIOService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "network:offline-status-changed", + offlineData: "offline", + otherDataValues: ["online", "this is not a valid data value"], + }); +}); + +// When captive-portal-login-success is observed and the suggestion is null, a +// fetch should start. +add_tasks_with_rust(async function captivePortalLoginSuccess_null() { + // See nsIIOService for possible data values. + await doOnlineTestWithNullSuggestion({ + topic: "captive-portal-login-success", + otherDataValues: [""], + }); +}); + +async function doOnlineTestWithNullSuggestion({ + topic, + otherDataValues, + offlineData = "", +}) { + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + let timer = QuickSuggest.weather._test_fetchTimer; + + // First, send the notification with the offline data value. Nothing should + // happen. + if (offlineData) { + info("Sending notification: " + JSON.stringify({ topic, offlineData })); + QuickSuggest.weather.observe(null, topic, offlineData); + + Assert.ok( + !QuickSuggest.weather.suggestion, + "Suggestion should remain null" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimer, + timer, + "Timer should not have been recreated" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be the full fetch interval" + ); + } + + // Now send it with all other data values. Fetches should be triggered. + for (let data of otherDataValues) { + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not have started yet" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "A new timer should have been created" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs, + "Timer ms should be fetchDelayAfterComingOnlineMs" + ); + + timer = QuickSuggest.weather._test_fetchTimer; + + info("Waiting for fetch after notification"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not be pending" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + timer, + "A new timer should have been created" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + timer = QuickSuggest.weather._test_fetchTimer; + } +} + +// When many online notifications are received at once, only one fetch should +// start. +add_tasks_with_rust(async function manyOnlineNotifications() { + await doManyNotificationsTest([ + ["network:link-status-changed", "changed"], + ["network:link-status-changed", "up"], + ["network:offline-status-changed", "online"], + ]); +}); + +// When wake and online notifications are received at once, only one fetch +// should start. +add_tasks_with_rust(async function wakeAndOnlineNotifications() { + await doManyNotificationsTest([ + ["wake_notification", ""], + ["network:link-status-changed", "changed"], + ["network:link-status-changed", "up"], + ["network:offline-status-changed", "online"], + ]); +}); + +async function doManyNotificationsTest(notifications) { + // Make `Date.now()` return a value under our control, doesn't matter what it + // is. This is the time the first fetch period will start. + let nowOnStart = 100; + let sandbox = sinon.createSandbox(); + let dateNowStub = sandbox.stub( + Cu.getGlobalForObject(QuickSuggest.weather).Date, + "now" + ); + dateNowStub.returns(nowOnStart); + + // Start a first fetch period so that after we send the notifications below + // the last fetch time will be in the past. + info("Starting first fetch period"); + await QuickSuggest.weather._test_fetch(); + Assert.equal( + QuickSuggest.weather._test_lastFetchTimeMs, + nowOnStart, + "Last fetch time should be updated after fetch" + ); + + // Now advance the clock by many fetch intervals. + let nowOnWake = nowOnStart + 100 * QuickSuggest.weather._test_fetchIntervalMs; + dateNowStub.returns(nowOnWake); + + // Set the suggestion to null so online notifications will trigger a fetch. + QuickSuggest.weather._test_setSuggestionToNull(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Clear the server's list of received requests. + MerinoTestUtils.server.reset(); + MerinoTestUtils.server.response.body.suggestions = [ + MerinoTestUtils.WEATHER_SUGGESTION, + ]; + + // Send the notifications. + for (let [topic, data] of notifications) { + info("Sending notification: " + JSON.stringify({ topic, data })); + QuickSuggest.weather.observe(null, topic, data); + } + + info("Waiting for fetch after notifications"); + await QuickSuggest.weather.waitForFetches(); + + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 0, + "Fetch should not be pending" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Timer should exist" + ); + Assert.equal( + QuickSuggest.weather._test_fetchTimerMs, + QuickSuggest.weather._test_fetchIntervalMs, + "Timer period should be full fetch interval" + ); + + Assert.equal( + MerinoTestUtils.server.requests.length, + 1, + "Merino should have received only one request" + ); + + dateNowStub.restore(); +} + +// Fetching when a VPN is detected should set the suggestion to null, and +// turning off the VPN should trigger a re-fetch. +add_tasks_with_rust(async function vpn() { + // Register a mock object that implements nsINetworkLinkService. + let mockLinkService = { + isLinkUp: true, + linkStatusKnown: true, + linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI, + networkID: "abcd", + dnsSuffixList: [], + platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED, + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + }; + let networkLinkServiceCID = MockRegistrar.register( + "@mozilla.org/network/network-link-service;1", + mockLinkService + ); + QuickSuggest.weather._test_linkService = mockLinkService; + + // At this point no VPN is detected, so a fetch should complete successfully. + await QuickSuggest.weather._test_fetch(); + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should exist"); + + // Modify the mock link service to indicate a VPN is detected. + mockLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.VPN_DETECTED; + + // Now a fetch should set the suggestion to null. + await QuickSuggest.weather._test_fetch(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Set `weather.ignoreVPN` and fetch again. It should complete successfully. + UrlbarPrefs.set("weather.ignoreVPN", true); + await QuickSuggest.weather._test_fetch(); + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched"); + + // Clear the pref and fetch again. It should set the suggestion back to null. + UrlbarPrefs.clear("weather.ignoreVPN"); + await QuickSuggest.weather._test_fetch(); + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null"); + + // Simulate the link status changing. Since the mock link service still + // indicates a VPN is detected, the suggestion should remain null. + let fetchPromise = QuickSuggest.weather.waitForFetches(); + QuickSuggest.weather.observe(null, "network:link-status-changed", "changed"); + await fetchPromise; + Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should remain null"); + + // Modify the mock link service to indicate a VPN is no longer detected. + mockLinkService.platformDNSIndications = + Ci.nsINetworkLinkService.NONE_DETECTED; + + // Simulate the link status changing again. The suggestion should be fetched. + fetchPromise = QuickSuggest.weather.waitForFetches(); + QuickSuggest.weather.observe(null, "network:link-status-changed", "changed"); + await fetchPromise; + Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched"); + + MockRegistrar.unregister(networkLinkServiceCID); + delete QuickSuggest.weather._test_linkService; +}); + +// When a Nimbus experiment is installed, it should override the remote settings +// weather record. +add_tasks_with_rust(async function nimbusOverride() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + let defaultResult = makeWeatherResult(); + + // Verify a search works as expected with the default remote settings weather + // record (which was added in the init task). + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [defaultResult], + }); + + // Install an experiment with a different keyword and min length. + let nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({ + weatherKeywords: ["nimbusoverride"], + weatherKeywordsMinimumLength: "nimbus".length, + }); + + // The usual default keyword shouldn't match. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + + // The new keyword from Nimbus should match. Since keywords are defined in + // Nimbus, the result will be served from UrlbarProviderWeather and its source + // will be "merino", not "rust", even when Rust is enabled. + let merinoResult = makeWeatherResult({ + source: "merino", + provider: "accuweather", + telemetryType: null, + }); + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [merinoResult], + }); + await check_results({ + context: createContext("nimbus", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [merinoResult], + }); + + // Uninstall the experiment. + await nimbusCleanup(); + + // The usual default keyword should match again. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [defaultResult], + }); + + // The keywords from Nimbus shouldn't match anymore. + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + await check_results({ + context: createContext("nimbus", { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); +}); + +function assertEnabled({ message, hasSuggestion, pendingFetchCount }) { + info("Asserting feature is enabled"); + if (message) { + info(message); + } + + Assert.equal( + !!QuickSuggest.weather.suggestion, + hasSuggestion, + "Suggestion is null or non-null as expected" + ); + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is non-zero" + ); + Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null"); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + pendingFetchCount, + "Expected pending fetch count" + ); +} + +function assertDisabled({ message, pendingFetchCount }) { + info("Asserting feature is disabled"); + if (message) { + info(message); + } + + Assert.strictEqual( + QuickSuggest.weather.suggestion, + null, + "Suggestion is null" + ); + Assert.strictEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is zero" + ); + Assert.strictEqual( + QuickSuggest.weather._test_merino, + null, + "Merino client is null" + ); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + pendingFetchCount, + "Expected pending fetch count" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js new file mode 100644 index 0000000000..efa5922c3e --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js @@ -0,0 +1,1503 @@ +/* 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 keywords behavior of quick suggest weather. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const { WEATHER_RS_DATA } = MerinoTestUtils; + +add_setup(async () => { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsRecords: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + prefs: [["suggest.quicksuggest.nonsponsored", true]], + }); + await MerinoTestUtils.initWeather(); +}); + +// * Settings data: none +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: no suggestion +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "No data", + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: empty +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: no suggestion +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Empty settings", + settingsData: {}, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: keywords only +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: full keywords only +// +// JS backend only. The Rust component expects settings data to contain +// min_keyword_length. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings only, keywords only", + settingsData: { + keywords: ["weather", "forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: full keywords only +// +// JS backend only. The Rust component doesn't treat minKeywordLength == 0 as a +// special case. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length = 0", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 0, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: use settings data +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length > 0", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + tests: { + "": false, + w: false, + we: false, + wea: true, + weat: true, + weath: true, + weathe: true, + weather: true, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: none +// * Min keyword length pref: 6 +// * Expected: use settings keywords and min keyword length pref +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length = 0, pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 0, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: none +// * Min keyword length pref: 6 +// * Expected: use settings keywords and min keyword length pref +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings only, min keyword length > 0, pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + minKeywordLength: 6, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: empty +// * Nimbus values: empty +// * Min keyword length pref: none +// * Expected: no suggestion +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: empty; Nimbus: empty", + settingsData: {}, + nimbusValues: {}, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// * Settings data: keywords only +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords in Nimbus +// +// JS backend only. The Rust component expects settings data to contain +// min_keyword_length. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: keywords; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length = 0 +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords in Nimbus +// +// JS backend only. The Rust component doesn't treat minKeywordLength == 0 as a +// special case. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length = 0; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + min_keyword_length: 0, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: Nimbus keywords with settings min keyword length. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: none +// * Expected: Nimbus keywords with settings min keyword length. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 0, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: none +// * Expected: use Nimbus values. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 4, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: exists +// * Expected: Nimbus keywords with min keyword length pref. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0; pref exists", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 0, + }, + minKeywordLength: 6, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: keywords and min keyword length > 0 +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: exists +// * Expected: Nimbus keywords with min keyword length pref +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0; pref exists", + settingsData: { + keywords: ["weather", "forecast"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + weatherKeywordsMinimumLength: 4, + }, + minKeywordLength: 6, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords only +// * Min keyword length pref: none +// * Expected: full keywords +// +// TODO bug 1879209: This doesn't work with the Rust backend because if +// min_keyword_length isn't specified on ingest, the Rust database will retain +// the last known good min_keyword_length, which interferes with this task. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length = 0 +// * Min keyword length pref: none +// * Expected: full keywords +// +// TODO bug 1879209: This doesn't work with the Rust backend because if +// min_keyword_length isn't specified on ingest, the Rust database will retain +// the last known good min_keyword_length, which interferes with this task. +add_task( + { + skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"), + }, + async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length = 0", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 0, + }, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: true, + }, + }); + } +); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: none +// * Expected: use Nimbus values +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 3, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: true, + weat: true, + weath: true, + weathe: true, + weather: true, + f: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// * Settings data: none +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: exists +// * Expected: use Nimbus keywords and min keyword length pref +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0; pref exists", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 3, + }, + minKeywordLength: 6, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: true, + weather: true, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + forecast: true, + }, + }); +}); + +// When `weatherKeywords` is non-null and `weatherKeywordsMinimumLength` is +// larger than the length of all keywords, the suggestion should not be +// triggered. +// Even when Rust is enabled, UrlbarProviderWeather should serve the +// suggestion since the keywords come from Nimbus. +add_tasks_with_rust(async function minLength_large() { + await doKeywordsTest({ + desc: "Large min length", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 999, + }, + alwaysExpectMerinoResult: true, + tests: { + "": false, + w: false, + we: false, + wea: false, + weat: false, + weath: false, + weathe: false, + weather: false, + f: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: false, + forecas: false, + forecast: false, + }, + }); +}); + +// Leading and trailing spaces should be ignored. +add_tasks_with_rust(async function leadingAndTrailingSpaces() { + await doKeywordsTest({ + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + tests: { + " wea": true, + " wea": true, + "wea ": true, + "wea ": true, + " wea ": true, + " weat": true, + " weat": true, + "weat ": true, + "weat ": true, + " weat ": true, + }, + }); +}); + +add_tasks_with_rust(async function caseInsensitive() { + await doKeywordsTest({ + desc: "Case insensitive", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + tests: { + wea: true, + WEA: true, + Wea: true, + WeA: true, + WEATHER: true, + Weather: true, + WeAtHeR: true, + }, + }); +}); + +async function doKeywordsTest({ + desc, + tests, + nimbusValues = null, + settingsData = null, + minKeywordLength = undefined, + alwaysExpectMerinoResult = false, +}) { + info("Doing keywords test: " + desc); + info(JSON.stringify({ nimbusValues, settingsData, minKeywordLength })); + + // If the JS backend is enabled, a suggestion hasn't already been fetched, and + // the data contains keywords, a fetch will start. Wait for it to finish later + // below. + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + !UrlbarPrefs.get("quickSuggestRustEnabled") && + (nimbusValues?.weatherKeywords || settingsData?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: settingsData, + }, + ]); + + if (minKeywordLength) { + UrlbarPrefs.set("weather.minKeywordLength", minKeywordLength); + } + + if (fetchPromise) { + info("Waiting for fetch"); + assertFetchingStarted({ pendingFetchCount: 1 }); + await fetchPromise; + info("Got fetch"); + } + + let expectedResult = makeWeatherResult( + !alwaysExpectMerinoResult + ? undefined + : { source: "merino", provider: "accuweather", telemetryType: null } + ); + + for (let [searchString, expected] of Object.entries(tests)) { + info( + "Doing keywords test search: " + + JSON.stringify({ + searchString, + expected, + }) + ); + + await check_results({ + context: createContext(searchString, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: expected ? [expectedResult] : [], + }); + } + + await nimbusCleanup?.(); + + fetchPromise = null; + if (!QuickSuggest.weather.suggestion) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + + UrlbarPrefs.clear("weather.minKeywordLength"); + await fetchPromise; +} + +// When a sponsored quick suggest result matches the same keyword as the weather +// result, the weather result should be shown and the quick suggest result +// should not be shown. +add_tasks_with_rust(async function matchingQuickSuggest_sponsored() { + await doMatchingQuickSuggestTest("suggest.quicksuggest.sponsored", true); +}); + +// When a non-sponsored quick suggest result matches the same keyword as the +// weather result, the weather result should be shown and the quick suggest +// result should not be shown. +add_tasks_with_rust(async function matchingQuickSuggest_nonsponsored() { + await doMatchingQuickSuggestTest("suggest.quicksuggest.nonsponsored", false); +}); + +async function doMatchingQuickSuggestTest(pref, isSponsored) { + let keyword = "test"; + + let attachment = isSponsored + ? { + id: 1, + url: "http://example.com/amp", + title: "AMP Suggestion", + keywords: [keyword], + click_url: "http://example.com/amp-click", + impression_url: "http://example.com/amp-impression", + advertiser: "Amp", + iab_category: "22 - Shopping", + icon: "1234", + } + : { + id: 2, + url: "http://example.com/wikipedia", + title: "Wikipedia Suggestion", + keywords: [keyword], + click_url: "http://example.com/wikipedia-click", + impression_url: "http://example.com/wikipedia-impression", + advertiser: "Wikipedia", + iab_category: "5 - Education", + icon: "1234", + }; + + // Add a remote settings result to quick suggest. + let oldPrefValue = UrlbarPrefs.get(pref); + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "data", + attachment: [attachment], + }, + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + + // First do a search to verify the quick suggest result matches the keyword. + let payload; + if (!UrlbarPrefs.get("quickSuggestRustEnabled")) { + payload = { + source: "remote-settings", + provider: "AdmWikipedia", + sponsoredImpressionUrl: attachment.impression_url, + sponsoredClickUrl: attachment.click_url, + sponsoredBlockId: attachment.id, + }; + } else { + payload = { + source: "rust", + provider: isSponsored ? "Amp" : "Wikipedia", + }; + if (isSponsored) { + payload.sponsoredImpressionUrl = attachment.impression_url; + payload.sponsoredClickUrl = attachment.click_url; + payload.sponsoredBlockId = attachment.id; + } + } + + info("Doing first search for quick suggest result"); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [ + { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + ...payload, + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + qsSuggestion: keyword, + title: attachment.title, + url: attachment.url, + displayUrl: attachment.url.replace(/[/]$/, ""), + originalUrl: attachment.url, + icon: null, + sponsoredAdvertiser: attachment.advertiser, + sponsoredIabCategory: attachment.iab_category, + isSponsored, + descriptionL10n: isSponsored + ? { id: "urlbar-result-action-sponsored" } + : undefined, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + isBlockable: true, + blockL10n: { + id: "urlbar-result-menu-dismiss-firefox-suggest", + }, + }, + }, + ], + }); + + // Set up the keyword for the weather suggestion and do a second search to + // verify only the weather result matches. + info("Doing second search for weather suggestion"); + let cleanup = await UrlbarTestUtils.initNimbusFeature({ + weatherKeywords: [keyword], + weatherKeywordsMinimumLength: 1, + }); + await check_results({ + context: createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name], + isPrivate: false, + }), + // The result should always come from Merino. + matches: [ + makeWeatherResult({ + source: "merino", + provider: "accuweather", + telemetryType: null, + }), + ], + }); + await cleanup(); + + UrlbarPrefs.set(pref, oldPrefValue); +} + +add_tasks_with_rust(async function () { + await doIncrementTest({ + desc: "Settings only without cap", + setup: { + settingsData: { + weather: { + keywords: ["forecast", "wind"], + min_keyword_length: 3, + }, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + fo: false, + for: true, + fore: true, + forec: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: true, + forec: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: false, + forec: true, + wi: false, + win: false, + wind: false, + }, + }, + ], + }); +}); + +add_tasks_with_rust(async function () { + await doIncrementTest({ + desc: "Settings only with cap", + setup: { + settingsData: { + weather: { + keywords: ["forecast", "wind"], + min_keyword_length: 3, + }, + configuration: { + show_less_frequently_cap: 3, + }, + }, + }, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + fo: false, + for: false, + fore: false, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +add_tasks_with_rust(async function () { + await doIncrementTest({ + desc: "Settings and Nimbus without cap", + setup: { + settingsData: { + weather: { + keywords: ["weather"], + min_keyword_length: 5, + }, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + }, + }, + // The suggestion should be served by UrlbarProviderWeather and therefore + // be from Merino. + alwaysExpectMerinoResult: true, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: true, + fore: true, + forec: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: true, + forec: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +add_task(async function () { + await doIncrementTest({ + desc: "Settings and Nimbus with cap in Nimbus", + setup: { + settingsData: { + weather: { + keywords: ["weather"], + min_keyword_length: 5, + }, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + weatherKeywordsMinimumLengthCap: 6, + }, + }, + // The suggestion should be served by UrlbarProviderWeather and therefore + // be from Merino. + alwaysExpectMerinoResult: true, + tests: [ + { + minKeywordLength: 3, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: true, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: true, + wind: true, + }, + }, + { + minKeywordLength: 4, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: true, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: true, + }, + }, + { + minKeywordLength: 5, + canIncrement: true, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: true, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + { + minKeywordLength: 6, + canIncrement: false, + searches: { + we: false, + wea: false, + weat: false, + weath: false, + fo: false, + for: false, + fore: false, + forec: false, + foreca: true, + forecas: true, + wi: false, + win: false, + wind: false, + windy: false, + }, + }, + ], + }); +}); + +async function doIncrementTest({ + desc, + setup, + tests, + alwaysExpectMerinoResult = false, +}) { + info("Doing increment test: " + desc); + info(JSON.stringify({ setup })); + + let { nimbusValues, settingsData } = setup; + + // If the JS backend is enabled, a suggestion hasn't already been fetched, and + // the data contains keywords, a fetch will start. Wait for it to finish later + // below. + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + !UrlbarPrefs.get("quickSuggestRustEnabled") && + (nimbusValues?.weatherKeywords || settingsData?.weather?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: settingsData?.weather, + }, + { + type: "configuration", + configuration: settingsData?.configuration, + }, + ]); + + if (fetchPromise) { + info("Waiting for fetch"); + assertFetchingStarted({ pendingFetchCount: 1 }); + await fetchPromise; + info("Got fetch"); + } + + let expectedResult = makeWeatherResult( + !alwaysExpectMerinoResult + ? undefined + : { source: "merino", provider: "accuweather", telemetryType: null } + ); + + for (let { minKeywordLength, canIncrement, searches } of tests) { + info( + "Doing increment test case: " + + JSON.stringify({ + minKeywordLength, + canIncrement, + }) + ); + + Assert.equal( + QuickSuggest.weather.minKeywordLength, + minKeywordLength, + "minKeywordLength should be correct" + ); + Assert.equal( + QuickSuggest.weather.canIncrementMinKeywordLength, + canIncrement, + "canIncrement should be correct" + ); + + for (let [searchString, expected] of Object.entries(searches)) { + await check_results({ + context: createContext(searchString, { + providers: [ + UrlbarProviderQuickSuggest.name, + UrlbarProviderWeather.name, + ], + isPrivate: false, + }), + matches: expected ? [expectedResult] : [], + }); + } + + QuickSuggest.weather.incrementMinKeywordLength(); + info( + "Incremented min keyword length, new value is: " + + QuickSuggest.weather.minKeywordLength + ); + } + + await nimbusCleanup?.(); + + fetchPromise = null; + if (!QuickSuggest.weather.suggestion) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + await QuickSuggestTestUtils.setRemoteSettingsRecords([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); + await fetchPromise; +} + +function assertFetchingStarted() { + info("Asserting fetching has started"); + + Assert.notEqual( + QuickSuggest.weather._test_fetchTimer, + 0, + "Fetch timer is non-zero" + ); + Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null"); + Assert.equal( + QuickSuggest.weather._test_pendingFetchCount, + 1, + "Expected pending fetch count" + ); +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml new file mode 100644 index 0000000000..ceab478795 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml @@ -0,0 +1,51 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "../../unit/head.js head.js" +firefox-appdir = "browser" + +["test_merinoClient.js"] + +["test_merinoClient_sessions.js"] + +["test_quicksuggest.js"] + +["test_quicksuggest_addons.js"] + +["test_quicksuggest_dynamicWikipedia.js"] + +["test_quicksuggest_impressionCaps.js"] +skip-if = ["true"] # Bug 1880214 + +["test_quicksuggest_mdn.js"] + +["test_quicksuggest_merino.js"] + +["test_quicksuggest_merinoSessions.js"] + +["test_quicksuggest_migrate_v1.js"] + +["test_quicksuggest_migrate_v2.js"] + +["test_quicksuggest_nonUniqueKeywords.js"] +skip-if = ["true"] # Bug 1880214 + +["test_quicksuggest_offlineDefault.js"] + +["test_quicksuggest_pocket.js"] + +["test_quicksuggest_positionInSuggestions.js"] +skip-if = ["true"] # Bug 1880214 + +["test_quicksuggest_scoreMap.js"] + +["test_quicksuggest_topPicks.js"] + +["test_quicksuggest_yelp.js"] + +["test_rust_ingest.js"] + +["test_suggestionsMap.js"] + +["test_weather.js"] + +["test_weather_keywords.js"] -- cgit v1.2.3