summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/tests/quicksuggest/unit
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/urlbar/tests/quicksuggest/unit
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/urlbar/tests/quicksuggest/unit')
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/head.js227
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js647
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js402
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js1341
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js728
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_bestMatch.js463
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js95
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js3888
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js681
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js174
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js490
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js1355
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js282
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js127
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js487
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js284
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js217
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_weather.js1394
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js1395
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/xpcshell.ini23
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]