diff options
Diffstat (limited to 'browser/components/urlbar/tests/quicksuggest/unit')
20 files changed, 14700 insertions, 0 deletions
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..39f920fce5 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/head.js @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* import-globals-from ../../unit/head.js */ + +ChromeUtils.defineESModuleGetters(this, { + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.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; +}); + +/** + * 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; +} 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..70e970af8a --- /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_task(async function init() { + 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..00e9820fab --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js @@ -0,0 +1,1341 @@ +/* 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 = "frab"; +const NONSPONSORED_SEARCH_STRING = "nonspon"; + +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 = [ + { + id: 1, + url: "http://test.com/q=frabbits", + title: "frabbits", + keywords: [SPONSORED_SEARCH_STRING], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + iab_category: "22 - Shopping", + }, + { + id: 2, + url: "http://test.com/?q=nonsponsored", + title: "Non-Sponsored", + keywords: [NONSPONSORED_SEARCH_STRING], + click_url: "http://click.reporting.test.com/nonsponsored", + impression_url: "http://impression.reporting.test.com/nonsponsored", + advertiser: "TestAdvertiserNonSponsored", + iab_category: "5 - Education", + }, + { + id: 3, + url: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + title: "http suggestion", + keywords: [HTTP_SEARCH_STRING], + click_url: "http://click.reporting.test.com/prefix", + impression_url: "http://impression.reporting.test.com/prefix", + advertiser: "TestAdvertiserPrefix", + iab_category: "22 - Shopping", + }, + { + 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", + }, + { + 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", + }, +]; + +const EXPECTED_SPONSORED_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: "frab", + title: "frabbits", + url: "http://test.com/q=frabbits", + originalUrl: "http://test.com/q=frabbits", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/", + sponsoredClickUrl: "http://click.reporting.test.com/", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/q=frabbits", + source: "remote-settings", + }, +}; + +const EXPECTED_NONSPONSORED_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_nonsponsored", + qsSuggestion: "nonspon", + title: "Non-Sponsored", + url: "http://test.com/?q=nonsponsored", + originalUrl: "http://test.com/?q=nonsponsored", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/nonsponsored", + sponsoredClickUrl: "http://click.reporting.test.com/nonsponsored", + sponsoredBlockId: 2, + sponsoredAdvertiser: "TestAdvertiserNonSponsored", + sponsoredIabCategory: "5 - Education", + isSponsored: false, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/?q=nonsponsored", + source: "remote-settings", + }, +}; + +const EXPECTED_HTTP_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: HTTP_SEARCH_STRING, + title: "http suggestion", + url: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + originalUrl: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/prefix", + sponsoredClickUrl: "http://click.reporting.test.com/prefix", + sponsoredBlockId: 3, + sponsoredAdvertiser: "TestAdvertiserPrefix", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + source: "remote-settings", + }, +}; + +const EXPECTED_HTTPS_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: HTTPS_SEARCH_STRING, + title: "https suggestion", + url: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + originalUrl: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL, + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/prefix", + sponsoredClickUrl: "http://click.reporting.test.com/prefix", + sponsoredBlockId: 4, + sponsoredAdvertiser: "TestAdvertiserPrefix", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: PREFIX_SUGGESTIONS_STRIPPED_URL, + source: "remote-settings", + }, +}; + +add_setup(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", false); + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", true); + UrlbarPrefs.set("merino.enabled", false); + + // 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({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + { + type: "test-data-type", + attachment: testDataTypeResults, + }, + ], + }); +}); + +// Tests with only non-sponsored suggestions enabled with a matching search +// string. +add_task(async function nonsponsoredOnly_match() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NONSPONSORED_RESULT], + }); +}); + +// Tests with only non-sponsored suggestions enabled with a non-matching search +// string. +add_task(async function nonsponsoredOnly_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + + 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_task(async function sponsoredOnly_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); +}); + +// Tests with only sponsored suggestions enabled with a non-matching search +// string. +add_task(async function sponsoredOnly_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + 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_task(async function both_sponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that matches the non-sponsored suggestion. +add_task(async function both_nonsponsored() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(NONSPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NONSPONSORED_RESULT], + }); +}); + +// Tests with both sponsored and non-sponsored suggestions enabled with a +// search string that doesn't match either suggestion. +add_task(async function both_noMatch() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + 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_task(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_task(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_task(async function caseInsensitiveAndLeadingSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + let context = createContext(" " + SPONSORED_SEARCH_STRING.toUpperCase(), { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); +}); + +// The provider should not be active for search strings that are empty or +// contain only spaces. +add_task(async function emptySearchStringsAndSpaces() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + 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_task(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); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); + + UrlbarPrefs.clear("browser.search.suggest.enabled"); +}); + +// Results should be returned even when `browser.urlbar.suggest.searches` is +// false. +add_task(async function browser_search_suggest_enabled() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.searches", false); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_SPONSORED_RESULT], + }); + + UrlbarPrefs.clear("suggest.searches"); +}); + +// Neither sponsored nor non-sponsored results should appear in private contexts +// even when suggestions in private windows are enabled. +add_task(async function privateContext() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + 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_task(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); + + 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, + }), + EXPECTED_SPONSORED_RESULT, + ], + }); + + 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_task(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); + + 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, + EXPECTED_SPONSORED_RESULT, + ], + }); + + 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_task(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); + + 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, + }), + EXPECTED_SPONSORED_RESULT, + 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_task(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); + + 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, + EXPECTED_SPONSORED_RESULT, + 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_task(async function dedupeAgainstURL_samePrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: EXPECTED_HTTP_RESULT, + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_task(async function dedupeAgainstURL_higherPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTPS_SEARCH_STRING, + expectedQuickSuggestResult: EXPECTED_HTTPS_RESULT, + otherPrefix: "http://", + expectOther: false, + }); +}); + +add_task(async function dedupeAgainstURL_lowerPrefix() { + await doDedupeAgainstURLTest({ + searchString: HTTP_SEARCH_STRING, + expectedQuickSuggestResult: EXPECTED_HTTP_RESULT, + 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); + + 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"); + UrlbarPrefs.clear("suggest.searches"); + await PlacesUtils.history.clear(); +} + +// Tests the remote settings latency histogram. +add_task(async function latencyTelemetry() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + 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: [EXPECTED_SPONSORED_RESULT], + }); + + // 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(async function setupAndTeardown() { + // 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( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling suggest prefs" + ); + + // 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( + QuickSuggestRemoteSettings.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client is non-null after enabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client remains non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client remains non-null after disabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.sponsored", false); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling suggest.quicksuggest.sponsored" + ); + + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + Assert.ok( + QuickSuggestRemoteSettings.rs, + "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored" + ); + + UrlbarPrefs.set("quicksuggest.enabled", false); + Assert.ok( + !QuickSuggestRemoteSettings.rs, + "Settings client is null after disabling quicksuggest.enabled" + ); + + // 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( + !QuickSuggestRemoteSettings.rs, + "Settings client remains null at end of task" + ); +}); + +// Timestamp templates in URLs should be replaced with real timestamps. +add_task(async function timestamps() { + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + // Do a search. + let context = createContext(TIMESTAMP_SEARCH_STRING, { + 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: 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_task(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); + context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false }); + + // The expected quick suggest result without the timestamp-related payload + // properties. + let expectedQuickSuggest = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + originalUrl: TIMESTAMP_SUGGESTION_URL, + qsSuggestion: TIMESTAMP_SEARCH_STRING, + title: "Timestamp suggestion", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/timestamp", + sponsoredBlockId: 5, + sponsoredAdvertiser: "TestAdvertiserTimestamp", + sponsoredIabCategory: "22 - Shopping", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, + }; + + let expectedResults = [ + expectedHeuristic, + ...expectedBadTimestampResults, + expectedQuickSuggest, + ]; + + let controller = UrlbarTestUtils.newMockController({ + input: { + isPrivate: false, + onFirstResult() { + return false; + }, + getSearchSource() { + return "dummy-search-source"; + }, + window: { + location: { + href: AppConstants.BROWSER_CHROME_URL, + }, + }, + }, + }); + 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"); + 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" + ); +}); + +// Test whether the blocking for remote settings results works. +add_task(async function block() { + for (const result of REMOTE_SETTINGS_RESULTS) { + await QuickSuggest.blockedSuggestions.add(result.url); + } + + for (const result of REMOTE_SETTINGS_RESULTS) { + const context = createContext(result.keywords[0], { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + 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(async function remoteSettingsDataType() { + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + + 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 = EXPECTED_SPONSORED_RESULT; + if (dataType) { + expected = JSON.parse(JSON.stringify(expected)); + expected.payload.title = dataType; + } + + // Re-enable to trigger sync from remote settings. + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", false); + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", true); + + let context = createContext(SPONSORED_SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expected], + }); + + await cleanUpNimbus(); + } +}); 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..d667fe35b7 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js @@ -0,0 +1,728 @@ +/* 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", +}); + +const MERINO_SUGGESTIONS = [ + { + provider: "amo", + icon: "icon", + url: "url", + 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, + is_top_pick: true, + }, + { + 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, + is_top_pick: false, + }, + { + 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, + }, + ], + }, +]; + +add_setup(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("addons.featureGate", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: REMOTE_SETTINGS_RESULTS, + merinoSuggestions: MERINO_SUGGESTIONS, + }); +}); + +// When non-sponsored suggestions are disabled, addon suggestions should be +// disabled. +add_task(async function nonsponsoredDisabled() { + // Disable sponsored suggestions. Addon 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({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + isTopPick: true, + }), + ], + }); + + // 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"); +}); + +// When addon suggestions specific preference is disabled, addon suggestions +// should not be added. +add_task(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", + isTopPick: true, + }), + ], + }); + + // Now disable the pref. + UrlbarPrefs.set(pref, false); + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [], + }); + + // Revert. + UrlbarPrefs.set(pref, true); + } +}); + +// Check wheather the addon suggestions will be shown by the setup of Nimbus +// variable. +add_task(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 check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + isTopPick: true, + }), + ], + }); + await cleanUpNimbusEnable(); + + // Enable locally. + UrlbarPrefs.set("addons.featureGate", true); + + // 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.set("addons.featureGate", true); +}); + +add_task(async function hideIfAlreadyInstalled() { + // Show suggestion. + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + suggestion: MERINO_SUGGESTIONS[0], + source: "merino", + isTopPick: true, + }), + ], + }); + + // 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_task(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", + isTopPick: true, + }), + }, + { + input: "1st", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "t", + expected: null, + }, + { + input: "tw", + expected: null, + }, + { + input: "two", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two w", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two wo", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two wor", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two word", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "two words", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a b", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a b ", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "a b c", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "second", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + isTopPick: false, + }), + }, + { + input: "2nd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1], + source: "remote-settings", + isTopPick: false, + }), + }, + { + input: "third", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + isTopPick: true, + }), + }, + { + input: "3rd", + expected: makeExpectedResult({ + suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2], + source: "remote-settings", + isTopPick: true, + }), + }, + ]; + + // Disable Merino so we trigger only remote settings suggestions. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + for (const { 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", + isTopPick: false, + }), + ], + }); + + // 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", + isTopPick: true, + }), + ], + }); +}); + +// Tests "show less frequently" with the cap set in remote settings. +add_task(async function showLessFrequently_rs() { + await doShowLessFrequentlyTest({ + rs: { + show_less_frequently_cap: 3, + }, + tests: [ + { + showLessFrequentlyCount: 0, + canShowLessFrequently: true, + searches: { + f: false, + fi: false, + fir: false, + firs: false, + first: true, + t: false, + tw: false, + two: true, + "two ": true, + "two w": true, + "two wo": true, + "two wor": true, + "two word": true, + "two words": true, + a: true, + "a ": true, + "a b": true, + "a b ": true, + "a b c": true, + }, + }, + { + showLessFrequentlyCount: 1, + canShowLessFrequently: true, + searches: { + first: false, + two: false, + a: false, + }, + }, + { + showLessFrequentlyCount: 2, + canShowLessFrequently: true, + searches: { + "two ": false, + "a ": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: { + "two w": false, + "a b": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: {}, + }, + ], + }); +}); + +// Tests "show less frequently" with the cap set in both Nimbus and remote +// settings. Nimbus should override remote settings. +add_task(async function showLessFrequently_nimbus() { + await doShowLessFrequentlyTest({ + nimbus: { + addonsShowLessFrequentlyCap: 3, + }, + rs: { + show_less_frequently_cap: 10, + }, + tests: [ + { + showLessFrequentlyCount: 0, + canShowLessFrequently: true, + searches: { + a: true, + "a ": true, + "a b": true, + "a b ": true, + "a b c": true, + }, + }, + { + showLessFrequentlyCount: 1, + canShowLessFrequently: true, + searches: { + a: false, + }, + }, + { + showLessFrequentlyCount: 2, + canShowLessFrequently: true, + searches: { + "a ": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: { + "a b": false, + }, + }, + { + showLessFrequentlyCount: 3, + canShowLessFrequently: false, + searches: {}, + }, + ], + }); +}); + +/** + * Does a group of searches, increments the `showLessFrequentlyCount`, 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 {object} options.tests + * An array where each item describes a group of searches to perform and + * expected state. Each item should look like this: + * `{ showLessFrequentlyCount, canShowLessFrequently, searches }` + * + * {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} searches + * 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` objects are cumulative: The intended use is to + * pass a large initial group of searches in the first search group, and + * then each following `searches` 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 doShowLessFrequentlyTest({ tests, rs = {}, nimbus = {} }) { + // Disable Merino so we trigger only remote settings suggestions. + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false); + + // We'll be testing with the first remote settings suggestion. + let suggestion = REMOTE_SETTINGS_RESULTS[0].attachment[0]; + + let addonSuggestions = QuickSuggest.getFeature("AddonSuggestions"); + + // 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, + searches, + } of tests) { + Assert.equal( + addonSuggestions.showLessFrequentlyCount, + showLessFrequentlyCount, + "showLessFrequentlyCount should be correct initially" + ); + Assert.equal( + UrlbarPrefs.get("addons.showLessFrequentlyCount"), + showLessFrequentlyCount, + "Pref should be correct initially" + ); + Assert.equal( + addonSuggestions.canShowLessFrequently, + canShowLessFrequently, + "canShowLessFrequently should be correct initially" + ); + + // Merge the current `searches` object into the cumulative object. + cumulativeSearches = { + ...cumulativeSearches, + ...searches, + }; + + for (let [searchString, isExpected] of Object.entries( + cumulativeSearches + )) { + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: !isExpected + ? [] + : [ + makeExpectedResult({ + suggestion, + source: "remote-settings", + isTopPick: true, + }), + ], + }); + } + + addonSuggestions.incrementShowLessFrequentlyCount(); + } + }, + }); + + await cleanUpNimbus(); + UrlbarPrefs.clear("addons.showLessFrequentlyCount"); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); +} + +function makeExpectedResult({ suggestion, source, isTopPick }) { + let rating; + let number_of_ratings; + if (source === "remote-settings") { + rating = suggestion.rating; + number_of_ratings = suggestion.number_of_ratings; + } else { + rating = suggestion.custom_details.amo.rating; + number_of_ratings = suggestion.custom_details.amo.number_of_ratings; + } + + return { + isBestMatch: isTopPick, + suggestedIndex: isTopPick ? 1 : -1, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "amo", + dynamicType: "addons", + title: suggestion.title, + url: suggestion.url, + displayUrl: suggestion.url.replace(/^https:\/\//, ""), + icon: suggestion.icon, + description: suggestion.description, + rating: Number(rating), + reviews: Number(number_of_ratings), + shouldNavigate: true, + helpUrl: QuickSuggest.HELP_URL, + source, + }, + }; +} diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_bestMatch.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_bestMatch.js new file mode 100644 index 0000000000..853073a6c0 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_bestMatch.js @@ -0,0 +1,463 @@ +/* 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 best match quick suggest results. "Best match" refers to two different +// concepts: +// +// (1) 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) The quick suggest config in remote settings can contain a `best_match` +// object that tells Firefox to use the best match UI treatment if the +// user's search string is a certain length. +// +// This file tests aspects of both concepts. +// +// See also test_quicksuggest_topPicks.js. "Top picks" refer to a similar +// concept but it is not related to (2). + +"use strict"; + +const MAX_RESULT_COUNT = UrlbarPrefs.get("maxRichResults"); + +// This search string length needs to be >= 4 to trigger its suggestion as a +// best match instead of a usual quick suggest. +const BEST_MATCH_POSITION_SEARCH_STRING = "bestmatchposition"; +const BEST_MATCH_POSITION = Math.round(MAX_RESULT_COUNT / 2); + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://example.com/", + title: "Fullkeyword title", + keywords: [ + "fu", + "ful", + "full", + "fullk", + "fullke", + "fullkey", + "fullkeyw", + "fullkeywo", + "fullkeywor", + "fullkeyword", + ], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + }, + { + id: 2, + url: "http://example.com/best-match-position", + title: `${BEST_MATCH_POSITION_SEARCH_STRING} title`, + keywords: [BEST_MATCH_POSITION_SEARCH_STRING], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + position: BEST_MATCH_POSITION, + }, +]; + +const EXPECTED_BEST_MATCH_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + isBestMatch: true, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/", + originalUrl: "http://example.com/", + title: "Fullkeyword title", + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("bestMatchBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://example.com", + source: "remote-settings", + }, +}; + +const EXPECTED_NON_BEST_MATCH_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/", + originalUrl: "http://example.com/", + title: "Fullkeyword title", + qsSuggestion: "fullkeyword", + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://example.com", + source: "remote-settings", + }, +}; + +const EXPECTED_BEST_MATCH_POSITION_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + isBestMatch: true, + payload: { + telemetryType: "adm_sponsored", + url: "http://example.com/best-match-position", + originalUrl: "http://example.com/best-match-position", + title: `${BEST_MATCH_POSITION_SEARCH_STRING} title`, + icon: null, + isSponsored: true, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 2, + sponsoredAdvertiser: "TestAdvertiser", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("bestMatchBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://example.com/best-match-position", + source: "remote-settings", + }, +}; + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.bestmatch", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + config: QuickSuggestTestUtils.BEST_MATCH_CONFIG, + }); +}); + +// Tests a best match result. +add_task(async function bestMatch() { + let context = createContext("fullkeyword", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_BEST_MATCH_URLBAR_RESULT], + }); + + let result = context.results[0]; + + // The title should not include the full keyword and em dash, and the part of + // the title that the search string matches should be highlighted. + Assert.equal(result.title, "Fullkeyword title", "result.title"); + Assert.deepEqual( + result.titleHighlights, + [[0, "fullkeyword".length]], + "result.titleHighlights" + ); + + Assert.equal(result.suggestedIndex, 1, "result.suggestedIndex"); + Assert.equal( + !!result.isSuggestedIndexRelativeToGroup, + false, + "result.isSuggestedIndexRelativeToGroup" + ); +}); + +// Tests a usual, non-best match quick suggest result. +add_task(async function nonBestMatch() { + // Search for a substring of the full search string so we can test title + // highlights. + let context = createContext("fu", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + + let result = context.results[0]; + + // 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. + Assert.equal(result.title, "fullkeyword — Fullkeyword title", "result.title"); + Assert.deepEqual( + result.titleHighlights, + [["fu".length, "fullkeyword".length - "fu".length]], + "result.titleHighlights" + ); + + Assert.equal(result.suggestedIndex, -1, "result.suggestedIndex"); + Assert.equal( + result.isSuggestedIndexRelativeToGroup, + true, + "result.isSuggestedIndexRelativeToGroup" + ); +}); + +// Tests prefix keywords leading up to a best match. +add_task(async function prefixKeywords() { + let sawNonBestMatch = false; + let sawBestMatch = false; + for (let keyword of REMOTE_SETTINGS_RESULTS[0].keywords) { + info(`Searching for "${keyword}"`); + let context = createContext(keyword, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + + let expectedResult; + if (keyword.length < 4) { + expectedResult = EXPECTED_NON_BEST_MATCH_URLBAR_RESULT; + sawNonBestMatch = true; + } else { + expectedResult = EXPECTED_BEST_MATCH_URLBAR_RESULT; + sawBestMatch = true; + } + + await check_results({ + context, + matches: [expectedResult], + }); + } + + Assert.ok(sawNonBestMatch, "Sanity check: Saw a non-best match"); + Assert.ok(sawBestMatch, "Sanity check: Saw a best match"); +}); + +// When tab-to-search is shown in the same search, both it and the best match +// will have a `suggestedIndex` value of 1. The TTS should appear first. +add_task(async function tabToSearch() { + // 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); + + // 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.fullkeyword.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("fullkeyword", { + isPrivate: false, + }); + await check_results({ + context, + matches: [ + // search heuristic + makeSearchResult(context, { + engineName: Services.search.defaultEngine.name, + engineIconUri: Services.search.defaultEngine.iconURI?.spec, + 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, + }), + // best match + EXPECTED_BEST_MATCH_URLBAR_RESULT, + // visit + makeVisitResult(context, { + uri: engineURL, + title: `test visit for ${engineURL}`, + }), + ], + }); + + await cleanupPlaces(); + await extension.unload(); + + UrlbarPrefs.clear("tabToSearch.onboard.interactionsLeft"); +}); + +// When the best match feature gate is disabled, quick suggest results should be +// shown as the usual non-best match results. +add_task(async function disabled_featureGate() { + UrlbarPrefs.set("bestMatch.enabled", false); + await doDisabledTest(); + UrlbarPrefs.set("bestMatch.enabled", true); +}); + +// When the best match suggestions are disabled, quick suggest results should be +// shown as the usual non-best match results. +add_task(async function disabled_suggestions() { + UrlbarPrefs.set("suggest.bestmatch", false); + await doDisabledTest(); + UrlbarPrefs.set("suggest.bestmatch", true); +}); + +// When best match is disabled, quick suggest results should be shown as the +// usual, non-best match results. +async function doDisabledTest() { + let context = createContext("fullkeywor", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + + let result = context.results[0]; + + // 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. + Assert.equal(result.title, "fullkeyword — Fullkeyword title", "result.title"); + Assert.deepEqual( + result.titleHighlights, + [["fullkeywor".length, 1]], + "result.titleHighlights" + ); + + Assert.equal(result.suggestedIndex, -1, "result.suggestedIndex"); + Assert.equal( + result.isSuggestedIndexRelativeToGroup, + true, + "result.isSuggestedIndexRelativeToGroup" + ); +} + +// `suggestion.position` should be ignored when the suggestion is a best match. +add_task(async function position() { + Assert.greater( + BEST_MATCH_POSITION, + 1, + "Precondition: `suggestion.position` > the best match index" + ); + + UrlbarPrefs.set("quicksuggest.allowPositionInSuggestions", true); + + let context = createContext(BEST_MATCH_POSITION_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/${BEST_MATCH_POSITION_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.iconURI?.spec, + heuristic: true, + }), + // best match whose backing suggestion has a `position` + EXPECTED_BEST_MATCH_POSITION_URLBAR_RESULT, + // visits + ...visitResults.slice(0, MAX_RESULT_COUNT - 2), + ], + }); + + await cleanupPlaces(); + UrlbarPrefs.clear("quicksuggest.allowPositionInSuggestions"); +}); + +// Tests a suggestion that is blocked from being a best match. +add_task(async function blockedAsBestMatch() { + let config = QuickSuggestTestUtils.BEST_MATCH_CONFIG; + config.best_match.blocked_suggestion_ids = [1]; + await QuickSuggestTestUtils.withConfig({ + config, + callback: async () => { + let context = createContext("fullkeyword", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + }, + }); +}); + +// Tests without a best_match config to make sure nothing breaks. +add_task(async function noConfig() { + await QuickSuggestTestUtils.withConfig({ + config: {}, + callback: async () => { + let context = createContext("fullkeyword", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_NON_BEST_MATCH_URLBAR_RESULT], + }); + }, + }); +}); 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..04859c7404 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js @@ -0,0 +1,95 @@ +/* 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() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + }); +}); + +// 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"); +}); + +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", + 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..41706dabd8 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js @@ -0,0 +1,3888 @@ +/* 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", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, +}; + +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: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, +}; + +let gSandbox; +let gDateNowStub; +let gStartupDateMsStub; + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", true); + UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("bestMatch.enabled", false); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); + + // 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. Pass + // in true for `isPrivate` so we don't attempt to record the impression ping + // because otherwise the following PingCentre error is logged: + // "Structured Ingestion ping failure with error: undefined" + let isPrivate = true; + if (UrlbarProviderQuickSuggest._resultFromLastQuery) { + UrlbarProviderQuickSuggest._resultFromLastQuery.isVisible = true; + } + UrlbarProviderQuickSuggest.onEngagement(isPrivate, "engagement", context, { + selIndex: -1, + }); +} + +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_merino.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js new file mode 100644 index 0000000000..4327890a0d --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js @@ -0,0 +1,681 @@ +/* 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 PREF_MERINO_ENABLED = "merino.enabled"; +const PREF_REMOTE_SETTINGS_ENABLED = "quicksuggest.remoteSettings.enabled"; + +const SEARCH_STRING = "frab"; + +const REMOTE_SETTINGS_RESULTS = [ + { + id: 1, + url: "http://test.com/q=frabbits", + title: "frabbits", + keywords: [SEARCH_STRING], + click_url: "http://click.reporting.test.com/", + impression_url: "http://impression.reporting.test.com/", + advertiser: "TestAdvertiser", + }, +]; + +const EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: SEARCH_STRING, + title: "frabbits", + url: "http://test.com/q=frabbits", + originalUrl: "http://test.com/q=frabbits", + icon: null, + sponsoredImpressionUrl: "http://impression.reporting.test.com/", + sponsoredClickUrl: "http://click.reporting.test.com/", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "http://test.com/q=frabbits", + source: "remote-settings", + }, +}; + +const EXPECTED_MERINO_URLBAR_RESULT = { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: "full_keyword", + title: "title", + url: "url", + originalUrl: "url", + icon: null, + sponsoredImpressionUrl: "impression_url", + sponsoredClickUrl: "click_url", + sponsoredBlockId: 1, + sponsoredAdvertiser: "advertiser", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "url", + requestId: "request_id", + source: "merino", + }, +}; + +// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino +// fetch, so it's easiest to create `gClient` lazily too. +XPCOMUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", false); + + await MerinoTestUtils.server.start(); + + // Set up the remote settings client with the test data. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: REMOTE_SETTINGS_RESULTS, + }, + ], + }); + + Assert.equal( + typeof QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + "number", + "Sanity check: DEFAULT_SUGGESTION_SCORE is defined" + ); +}); + +// Tests with Merino enabled and remote settings disabled. +add_task(async function oneEnabled_merino() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + // Use a score lower than the remote settings score to make sure the + // suggestion is included regardless. + MerinoTestUtils.server.response.body.suggestions[0].score = + QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE / 2; + + 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(); +}); + +// Tests with Merino disabled and remote settings enabled. +add_task(async function oneEnabled_remoteSettings() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, false); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); + + MerinoTestUtils.checkAndClearHistograms({ + histograms, + response: null, + latencyRecorded: false, + client: gClient, + }); +}); + +// Tests with Merino enabled but with data collection disabled. Results should +// not be fetched from Merino in that case. Also tests with remote settings +// enabled. +add_task(async function dataCollectionDisabled() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, false); + + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT], + }); +}); + +// 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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + 2 * QuickSuggestRemoteSettings.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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + QuickSuggestRemoteSettings.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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + let histograms = MerinoTestUtils.getAndClearHistograms(); + + MerinoTestUtils.server.response.body.suggestions[0].score = + QuickSuggestRemoteSettings.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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + 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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + 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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + 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(); +}); + +// Tests with both Merino and remote settings disabled. +add_task(async function bothDisabled() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, false); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + 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: gClient, + }); +}); + +// When Merino returns multiple suggestions, the one with the largest score +// should be used. +add_task(async function multipleMerinoSuggestions() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + 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", + 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", + 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", + is_sponsored: true, + score: 0.2, + }, + ]; + + let context = createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [ + { + type: UrlbarUtils.RESULT_TYPE.URL, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + telemetryType: "adm_sponsored", + qsSuggestion: "multipleMerinoSuggestions 1 full_keyword", + title: "multipleMerinoSuggestions 1 title", + url: "multipleMerinoSuggestions 1 url", + originalUrl: "multipleMerinoSuggestions 1 url", + icon: "multipleMerinoSuggestions 1 icon", + sponsoredImpressionUrl: "multipleMerinoSuggestions 1 impression_url", + sponsoredClickUrl: "multipleMerinoSuggestions 1 click_url", + sponsoredBlockId: 1, + sponsoredAdvertiser: "multipleMerinoSuggestions 1 advertiser", + isSponsored: true, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: "multipleMerinoSuggestions 1 url", + requestId: "request_id", + source: "merino", + }, + }, + ], + }); + + 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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + 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_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, false); + 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); + gClient.resetSession(); +}); + +// Test whether the blocking for Merino results works. +add_task(async function block() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + for (const suggestion of MerinoTestUtils.server.response.body.suggestions) { + 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 best match. +add_task(async function bestMatch() { + UrlbarPrefs.set(PREF_MERINO_ENABLED, true); + UrlbarPrefs.set(PREF_REMOTE_SETTINGS_ENABLED, true); + UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true); + + // Simply enabling the best match feature should make the mock suggestion a + // best match because the search string length is greater than the required + // best match length. + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.bestmatch", true); + + let expectedResult = { ...EXPECTED_MERINO_URLBAR_RESULT }; + expectedResult.payload = { ...EXPECTED_MERINO_URLBAR_RESULT.payload }; + expectedResult.isBestMatch = true; + delete expectedResult.payload.qsSuggestion; + + await QuickSuggestTestUtils.withConfig({ + config: QuickSuggestTestUtils.BEST_MATCH_CONFIG, + callback: async () => { + let context = createContext(SEARCH_STRING, { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [expectedResult], + }); + + // 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"); + }, + }); + + UrlbarPrefs.clear("bestMatch.enabled"); + UrlbarPrefs.clear("suggest.bestmatch"); + + 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..935577c36c --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js @@ -0,0 +1,174 @@ +/* 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. +XPCOMUtils.defineLazyGetter( + this, + "gClient", + () => UrlbarProviderQuickSuggest._test_merino +); + +add_task(async function init() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("merino.enabled", true); + UrlbarPrefs.set("quicksuggest.remoteSettings.enabled", false); + UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + + await MerinoTestUtils.server.start(); + await QuickSuggestTestUtils.ensureQuickSuggestInit(); +}); + +// 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(); +}); + +// 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); + } +} + +// 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(); +}); + +function endEngagement(context = null, state = "engagement") { + UrlbarProviderQuickSuggest.onEngagement( + false, + state, + context || + createContext("endEngagement", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + { selIndex: -1 } + ); + + 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..8e80f3639a --- /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_task(async function init() { + 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..cd4a2149e6 --- /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_task(async function init() { + 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..84d1116e89 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js @@ -0,0 +1,282 @@ +/* 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"; + +// 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, + }, + { + keywords: ["aaa", "bbb"], + isSponsored: false, + score: 2 * QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: true, + score: 4 * QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["bbb"], + isSponsored: false, + score: 3 * QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + }, + { + keywords: ["ccc"], + isSponsored: true, + }, +]; + +// 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 () { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // 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, + block_id: qsResult.id, + is_sponsored: isSponsored, + score: + typeof score == "number" + ? score + : QuickSuggestRemoteSettings.DEFAULT_SUGGESTION_SCORE, + 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, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, + }); + } + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "data", + attachment: qsResults, + }, + ], + }); + + // 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 QuickSuggestRemoteSettings.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); + + // 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); + } +}); 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..7330dd4fd5 --- /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_task(async function init() { + 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_positionInSuggestions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js new file mode 100644 index 0000000000..2d1cf728c7 --- /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, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + displayUrl: suggest.url, + source: "remote-settings", + }, + }; +} + +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_task(async function setup() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.sponsored", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // Setup for quick suggest result. + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + 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, + ], + }, + ], + }); + + // 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_topPicks.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js new file mode 100644 index 0000000000..dd8b9dc575 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js @@ -0,0 +1,284 @@ +/* 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. However, +// the treatment must be enabled on Firefox via the `bestMatch.enabled` +// feature gate pref (Nimbus variable `bestMatchEnabled`) and the +// `suggest.bestMatch` pref, which corresponds to a checkbox in +// about:preferences. If the UI treatment is not enabled, Firefox should +// show the suggestion as usual. +// (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() { + UrlbarPrefs.set("quicksuggest.enabled", true); + UrlbarPrefs.set("bestMatch.enabled", true); + UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true); + + // Disable search suggestions so we don't hit the network. + Services.prefs.setBoolPref("browser.search.suggest.enabled", false); + + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + merinoSuggestions: MERINO_SUGGESTIONS, + }); +}); + +// 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(); +}); + +add_task(async function prefs_0() { + await doPrefsTest({ + bestMatchEnabled: false, + suggestBestMatch: false, + expected: { + isBestMatch: false, + suggestedIndex: -1, + }, + }); +}); + +add_task(async function prefs_1() { + await doPrefsTest({ + bestMatchEnabled: false, + suggestBestMatch: true, + expected: { + isBestMatch: false, + suggestedIndex: -1, + }, + }); +}); + +add_task(async function prefs_2() { + await doPrefsTest({ + bestMatchEnabled: true, + suggestBestMatch: false, + expected: { + isBestMatch: false, + suggestedIndex: -1, + }, + }); +}); + +add_task(async function prefs_3() { + await doPrefsTest({ + bestMatchEnabled: true, + suggestBestMatch: true, + expected: { + isBestMatch: true, + suggestedIndex: 1, + }, + }); +}); + +async function doPrefsTest({ + bestMatchEnabled, + suggestBestMatch, + expected: { isBestMatch, suggestedIndex }, +}) { + UrlbarPrefs.set("bestMatch.enabled", bestMatchEnabled); + UrlbarPrefs.set("suggest.bestmatch", suggestBestMatch); + + // The mock suggestion has `provider` set to "top_picks", but Firefox should + // use only `is_top_pick` to determine whether it should be shown as best + // match, regardless of the provider. To make sure, change the provider to + // something else. + let originalProviders = []; + let provider = "some_unknown_provider"; + for (let s of MerinoTestUtils.server.response.body.suggestions) { + originalProviders.push(s.provider); + s.provider = provider; + } + + await check_results({ + context: createContext("test", { + providers: [UrlbarProviderQuickSuggest.name], + isPrivate: false, + }), + matches: [ + makeExpectedResult({ + isBestMatch, + suggestedIndex, + telemetryType: provider, + }), + ], + }); + + UrlbarPrefs.clear("bestMatch.enabled"); + UrlbarPrefs.clear("suggest.bestmatch"); + + // Restore the original provider. + for (let s of MerinoTestUtils.server.response.body.suggestions) { + s.provider = originalProviders.shift(); + } +} + +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, + source: "merino", + 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_suggestionsMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js new file mode 100644 index 0000000000..598f0d89a5 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js @@ -0,0 +1,217 @@ +/* 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 chunking feature of `RemoteSettingsClient.#addResults()`. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SuggestionsMap: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", +}); + +// 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_task(async function init() { + // 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, 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 + ); + } +}); 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..04fbc0b9d8 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js @@ -0,0 +1,1394 @@ +/* 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_task(async function init() { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + UrlbarPrefs.set("quicksuggest.enabled", true); + + 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_task(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_task(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: [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, + }); + + // The suggestion should be returned for a search. + context = createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeExpectedResult()], + }); +} + +add_task(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.setRemoteSettingsResults([ + { + 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.setRemoteSettingsResults([ + { + 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: [makeExpectedResult()], + }); +}); + +// 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_task(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; + 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_task(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, + }); +}); + +// A fetch that doesn't return a suggestion should cause the last-fetched +// suggestion to be discarded. +add_task(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: [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_task(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: [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_task(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: [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_task(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: [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_task(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_task(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_task(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: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ 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: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult({ temperatureUnit: osUnit })], + }); + Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales"); + }); + } +} + +// Blocks a result and makes sure the weather pref is disabled. +add_task(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: [UrlbarProviderWeather.name], + isPrivate: false, + }); + await check_results({ + context, + matches: [makeExpectedResult()], + }); + + // Block the result. + UrlbarProviderWeather.onEngagement(false, "engagement", context, { + result: context.results[0], + selType: "dismiss", + selIndex: context.results[0].rowIndex, + }); + 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: [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; + 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_task(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_task(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_task(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_task(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_task(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_task(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_task(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_task(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_task(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_task(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_task(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_task(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 +// config. +add_task(async function nimbusOverride() { + // Sanity check initial state. + assertEnabled({ + message: "Sanity check initial state", + hasSuggestion: true, + pendingFetchCount: 0, + }); + + // Verify a search works as expected with the default remote settings config. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // 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: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + + // The new keyword from Nimbus should match. + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + await check_results({ + context: createContext("nimbus", { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // Uninstall the experiment. + await nimbusCleanup(); + + // The usual default keyword should match again. + await check_results({ + context: createContext(MerinoTestUtils.WEATHER_KEYWORD, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [makeExpectedResult()], + }); + + // The keywords from Nimbus shouldn't match anymore. + await check_results({ + context: createContext("nimbusoverride", { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: [], + }); + await check_results({ + context: createContext("nimbus", { + providers: [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" + ); +} + +function makeExpectedResult({ + suggestedIndex = 1, + temperatureUnit = undefined, +} = {}) { + if (!temperatureUnit) { + temperatureUnit = + Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + } + + return { + suggestedIndex, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + temperatureUnit, + url: WEATHER_SUGGESTION.url, + iconId: "6", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: true, + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + requestId: MerinoTestUtils.server.response.body.request_id, + source: "merino", + merinoProvider: "accuweather", + dynamicType: "weather", + city: WEATHER_SUGGESTION.city_name, + temperature: + WEATHER_SUGGESTION.current_conditions.temperature[temperatureUnit], + currentConditions: WEATHER_SUGGESTION.current_conditions.summary, + forecast: WEATHER_SUGGESTION.forecast.summary, + high: WEATHER_SUGGESTION.forecast.high[temperatureUnit], + low: WEATHER_SUGGESTION.forecast.low[temperatureUnit], + shouldNavigate: true, + }, + }; +} 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..b62a243fb0 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js @@ -0,0 +1,1395 @@ +/* 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 and zero-prefix behavior of quick suggest weather. + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs", +}); + +const { WEATHER_RS_DATA, WEATHER_SUGGESTION } = MerinoTestUtils; + +add_task(async function init() { + await QuickSuggestTestUtils.ensureQuickSuggestInit({ + remoteSettingsResults: [ + { + type: "weather", + weather: WEATHER_RS_DATA, + }, + ], + }); + UrlbarPrefs.set("quicksuggest.enabled", true); + await MerinoTestUtils.initWeather(); +}); + +// * Settings data: none +// * Nimbus values: none +// * Min keyword length pref: none +// * Expected: no suggestion +add_task(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_task(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 +add_task(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 +add_task(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_task(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_task(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_task(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_task(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 +add_task(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 +add_task(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 +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords", + settingsData: { + keywords: ["weather"], + min_keyword_length: 3, + }, + nimbusValues: { + weatherKeywords: ["forecast"], + }, + 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 +add_task(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, + }, + 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 +add_task(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, + }, + 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 +add_task(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, + 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 +add_task(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, + 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 +add_task(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 +add_task(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 +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 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: none +// * Nimbus values: keywords and min keyword length > 0 +// * Min keyword length pref: exists +// * Expected: use Nimbus keywords and min keyword length pref +add_task(async function () { + await doKeywordsTest({ + desc: "Settings: none; Nimbus: keywords, min keyword length > 0; pref exists", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 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, + }, + }); +}); + +// When `weatherKeywords` is non-null and `weatherKeywordsMinimumLength` is +// larger than the length of all keywords, the suggestion should not be +// triggered. +add_task(async function minLength_large() { + await doKeywordsTest({ + desc: "Large min length", + nimbusValues: { + weatherKeywords: ["weather", "forecast"], + weatherKeywordsMinimumLength: 999, + }, + 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_task(async function leadingAndTrailingSpaces() { + await doKeywordsTest({ + nimbusValues: { + weatherKeywords: ["weather"], + weatherKeywordsMinimumLength: 3, + }, + tests: { + " wea": true, + " wea": true, + "wea ": true, + "wea ": true, + " wea ": true, + " weat": true, + " weat": true, + "weat ": true, + "weat ": true, + " weat ": true, + }, + }); +}); + +add_task(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, +}) { + info("Doing keywords test: " + desc); + info(JSON.stringify({ nimbusValues, settingsData, minKeywordLength })); + + // If a suggestion hasn't already been fetched and the data contains keywords, + // a fetch will start. Wait for it to finish below. + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + (nimbusValues?.weatherKeywords || settingsData?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + 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"); + } + + for (let [searchString, expected] of Object.entries(tests)) { + info( + "Doing search: " + + JSON.stringify({ + searchString, + expected, + }) + ); + + let suggestedIndex = searchString ? 1 : 0; + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: expected ? [makeExpectedResult({ suggestedIndex })] : [], + }); + } + + await nimbusCleanup?.(); + + fetchPromise = null; + if (!QuickSuggest.weather.suggestion) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + 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_task(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_task(async function matchingQuickSuggest_nonsponsored() { + await doMatchingQuickSuggestTest("suggest.quicksuggest.nonsponsored", false); +}); + +async function doMatchingQuickSuggestTest(pref, isSponsored) { + let keyword = "test"; + let iab_category = isSponsored ? "22 - Shopping" : "5 - Education"; + + // Add a remote settings result to quick suggest. + UrlbarPrefs.set(pref, true); + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "data", + attachment: [ + { + id: 1, + url: "http://example.com/", + title: "Suggestion", + keywords: [keyword], + click_url: "http://example.com/click", + impression_url: "http://example.com/impression", + advertiser: "TestAdvertiser", + iab_category, + }, + ], + }, + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + + // First do a search to verify the quick suggest result matches the keyword. + 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: { + telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored", + qsSuggestion: keyword, + title: "Suggestion", + url: "http://example.com/", + displayUrl: "http://example.com", + originalUrl: "http://example.com/", + icon: null, + sponsoredImpressionUrl: "http://example.com/impression", + sponsoredClickUrl: "http://example.com/click", + sponsoredBlockId: 1, + sponsoredAdvertiser: "TestAdvertiser", + sponsoredIabCategory: iab_category, + isSponsored, + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: UrlbarPrefs.get("quickSuggestBlockingEnabled"), + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + source: "remote-settings", + }, + }, + ], + }); + + // 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, + }), + matches: [makeExpectedResult({ suggestedIndex: 1 })], + }); + await cleanup(); + + UrlbarPrefs.clear(pref); +} + +add_task(async function () { + await doIncrementTest({ + desc: "Settings only", + setup: { + settingsData: { + 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_task(async function () { + await doIncrementTest({ + desc: "Settings only with cap", + setup: { + settingsData: { + keywords: ["forecast", "wind"], + min_keyword_length: 3, + min_keyword_length_cap: 6, + }, + }, + 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_task(async function () { + await doIncrementTest({ + desc: "Settings and Nimbus", + setup: { + settingsData: { + keywords: ["weather"], + min_keyword_length: 5, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + }, + }, + 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: { + keywords: ["weather"], + min_keyword_length: 5, + }, + nimbusValues: { + weatherKeywords: ["forecast", "wind"], + weatherKeywordsMinimumLength: 3, + weatherKeywordsMinimumLengthCap: 6, + }, + }, + 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 }) { + info("Doing increment test: " + desc); + info(JSON.stringify({ setup })); + + let { nimbusValues, settingsData } = setup; + + let fetchPromise; + if ( + !QuickSuggest.weather.suggestion && + (nimbusValues?.weatherKeywords || settingsData?.keywords) + ) { + fetchPromise = QuickSuggest.weather.waitForFetches(); + } + + let nimbusCleanup; + if (nimbusValues) { + nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues); + } + + await QuickSuggestTestUtils.setRemoteSettingsResults([ + { + type: "weather", + weather: settingsData, + }, + ]); + + if (fetchPromise) { + info("Waiting for fetch"); + assertFetchingStarted({ pendingFetchCount: 1 }); + await fetchPromise; + info("Got fetch"); + } + + 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)) { + Assert.equal( + QuickSuggest.weather.keywords.has(searchString), + expected, + "Keyword should be present/absent as expected: " + searchString + ); + + await check_results({ + context: createContext(searchString, { + providers: [UrlbarProviderWeather.name], + isPrivate: false, + }), + matches: expected ? [makeExpectedResult({ suggestedIndex: 1 })] : [], + }); + } + + 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.setRemoteSettingsResults([ + { + type: "weather", + weather: MerinoTestUtils.WEATHER_RS_DATA, + }, + ]); + UrlbarPrefs.clear("weather.minKeywordLength"); + await fetchPromise; +} + +function makeExpectedResult({ + suggestedIndex = 0, + temperatureUnit = undefined, +} = {}) { + if (!temperatureUnit) { + temperatureUnit = + Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + } + + return { + suggestedIndex, + type: UrlbarUtils.RESULT_TYPE.DYNAMIC, + source: UrlbarUtils.RESULT_SOURCE.SEARCH, + heuristic: false, + payload: { + temperatureUnit, + url: WEATHER_SUGGESTION.url, + iconId: "6", + helpUrl: QuickSuggest.HELP_URL, + helpL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-learn-more-about-firefox-suggest" + : "firefox-suggest-urlbar-learn-more", + }, + isBlockable: true, + blockL10n: { + id: UrlbarPrefs.get("resultMenu") + ? "urlbar-result-menu-dismiss-firefox-suggest" + : "firefox-suggest-urlbar-block", + }, + requestId: MerinoTestUtils.server.response.body.request_id, + source: "merino", + merinoProvider: "accuweather", + dynamicType: "weather", + city: WEATHER_SUGGESTION.city_name, + temperature: + WEATHER_SUGGESTION.current_conditions.temperature[temperatureUnit], + currentConditions: WEATHER_SUGGESTION.current_conditions.summary, + forecast: WEATHER_SUGGESTION.forecast.summary, + high: WEATHER_SUGGESTION.forecast.high[temperatureUnit], + low: WEATHER_SUGGESTION.forecast.low[temperatureUnit], + shouldNavigate: true, + }, + }; +} + +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.ini b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.ini new file mode 100644 index 0000000000..45a17687ae --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.ini @@ -0,0 +1,23 @@ +[DEFAULT] +skip-if = toolkit == '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_bestMatch.js] +[test_quicksuggest_dynamicWikipedia.js] +[test_quicksuggest_impressionCaps.js] +[test_quicksuggest_merino.js] +[test_quicksuggest_merinoSessions.js] +[test_quicksuggest_migrate_v1.js] +[test_quicksuggest_migrate_v2.js] +[test_quicksuggest_nonUniqueKeywords.js] +[test_quicksuggest_offlineDefault.js] +[test_quicksuggest_positionInSuggestions.js] +[test_quicksuggest_topPicks.js] +[test_suggestionsMap.js] +[test_weather.js] +[test_weather_keywords.js] |