summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/tests/quicksuggest
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
parentInitial commit. (diff)
downloadfirefox-esr-upstream.tar.xz
firefox-esr-upstream.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/urlbar/tests/quicksuggest')
-rw-r--r--browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs765
-rw-r--r--browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs1017
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser.ini37
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js88
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js560
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js445
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js2101
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js425
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js142
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js1596
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js114
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js477
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js353
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js368
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js409
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js367
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js152
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js379
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/head.js569
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs57
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml11
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml14
-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
42 files changed, 25146 insertions, 0 deletions
diff --git a/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs
new file mode 100644
index 0000000000..7763881b4f
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs
@@ -0,0 +1,765 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+});
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+// The following properties and methods are copied from the test scope to the
+// test utils object so they can be easily accessed. Be careful about assuming a
+// particular property will be defined because depending on the scope -- browser
+// test or xpcshell test -- some may not be.
+const TEST_SCOPE_PROPERTIES = [
+ "Assert",
+ "EventUtils",
+ "info",
+ "registerCleanupFunction",
+];
+
+const SEARCH_PARAMS = {
+ CLIENT_VARIANTS: "client_variants",
+ PROVIDERS: "providers",
+ QUERY: "q",
+ SEQUENCE_NUMBER: "seq",
+ SESSION_ID: "sid",
+};
+
+const REQUIRED_SEARCH_PARAMS = [
+ SEARCH_PARAMS.QUERY,
+ SEARCH_PARAMS.SEQUENCE_NUMBER,
+ SEARCH_PARAMS.SESSION_ID,
+];
+
+// We set the client timeout to a large value to avoid intermittent failures in
+// CI, especially TV tests, where the Merino fetch unexpectedly doesn't finish
+// before the default timeout.
+const CLIENT_TIMEOUT_MS = 2000;
+
+const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS";
+const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE";
+
+// Maps from string labels of the `FX_URLBAR_MERINO_RESPONSE` histogram to their
+// numeric values.
+const RESPONSE_HISTOGRAM_VALUES = {
+ success: 0,
+ timeout: 1,
+ network_error: 2,
+ http_error: 3,
+ no_suggestion: 4,
+};
+
+const WEATHER_KEYWORD = "weather";
+
+const WEATHER_RS_DATA = {
+ keywords: [WEATHER_KEYWORD],
+};
+
+const WEATHER_SUGGESTION = {
+ title: "Weather for San Francisco",
+ url: "https://example.com/weather",
+ provider: "accuweather",
+ is_sponsored: false,
+ score: 0.2,
+ icon: null,
+ city_name: "San Francisco",
+ current_conditions: {
+ url: "https://example.com/weather-current-conditions",
+ summary: "Mostly cloudy",
+ icon_id: 6,
+ temperature: { c: 15.5, f: 60.0 },
+ },
+ forecast: {
+ url: "https://example.com/weather-forecast",
+ summary: "Pleasant Saturday",
+ high: { c: 21.1, f: 70.0 },
+ low: { c: 13.9, f: 57.0 },
+ },
+};
+
+// We set the weather suggestion fetch interval to an absurdly large value so it
+// absolutely will not fire during tests.
+const WEATHER_FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+/**
+ * Test utils for Merino.
+ */
+class _MerinoTestUtils {
+ /**
+ * Initializes the utils.
+ *
+ * @param {object} scope
+ * The global JS scope where tests are being run. This allows the instance
+ * to access test helpers like `Assert` that are available in the scope.
+ */
+ init(scope) {
+ if (!scope) {
+ throw new Error("MerinoTestUtils.init() must be called with a scope");
+ }
+
+ this.#initDepth++;
+ scope.info?.("MerinoTestUtils init: Depth is now " + this.#initDepth);
+
+ for (let p of TEST_SCOPE_PROPERTIES) {
+ this[p] = scope[p];
+ }
+ // If you add other properties to `this`, null them in `uninit()`.
+
+ if (!this.#server) {
+ this.#server = new MockMerinoServer(scope);
+ }
+ lazy.UrlbarPrefs.set("merino.timeoutMs", CLIENT_TIMEOUT_MS);
+ scope.registerCleanupFunction?.(() => {
+ scope.info?.("MerinoTestUtils cleanup function");
+ this.uninit();
+ });
+ }
+
+ /**
+ * Uninitializes the utils. If they were created with a test scope that
+ * defines `registerCleanupFunction()`, you don't need to call this yourself
+ * because it will automatically be called as a cleanup function. Otherwise
+ * you'll need to call this.
+ */
+ uninit() {
+ this.#initDepth--;
+ this.info?.("MerinoTestUtils uninit: Depth is now " + this.#initDepth);
+
+ if (this.#initDepth) {
+ this.info?.("MerinoTestUtils uninit: Bailing because depth > 0");
+ return;
+ }
+ this.info?.("MerinoTestUtils uninit: Now uninitializing");
+
+ for (let p of TEST_SCOPE_PROPERTIES) {
+ this[p] = null;
+ }
+ this.#server.uninit();
+ this.#server = null;
+ lazy.UrlbarPrefs.clear("merino.timeoutMs");
+ }
+
+ /**
+ * @returns {object}
+ * The names of URL search params.
+ */
+ get SEARCH_PARAMS() {
+ return SEARCH_PARAMS;
+ }
+
+ /**
+ * @returns {string}
+ * The weather keyword in `WEATHER_RS_DATA`. Can be used as a search string
+ * to match the weather suggestion.
+ */
+ get WEATHER_KEYWORD() {
+ return WEATHER_KEYWORD;
+ }
+
+ /**
+ * @returns {object}
+ * Default remote settings data that sets up `WEATHER_KEYWORD` as the
+ * keyword for the weather suggestion.
+ */
+ get WEATHER_RS_DATA() {
+ return { ...WEATHER_RS_DATA };
+ }
+
+ /**
+ * @returns {object}
+ * A mock weather suggestion.
+ */
+ get WEATHER_SUGGESTION() {
+ return WEATHER_SUGGESTION;
+ }
+
+ /**
+ * @returns {MockMerinoServer}
+ * The mock Merino server. The server isn't started until its `start()`
+ * method is called.
+ */
+ get server() {
+ return this.#server;
+ }
+
+ /**
+ * Clears the Merino-related histograms and returns them.
+ *
+ * @param {object} options
+ * Options
+ * @param {string} options.extraLatency
+ * The name of another latency histogram you expect to be updated.
+ * @param {string} options.extraResponse
+ * The name of another response histogram you expect to be updated.
+ * @returns {object}
+ * An object of histograms: `{ latency, response }`
+ * `latency` and `response` are both arrays of Histogram objects.
+ */
+ getAndClearHistograms({
+ extraLatency = undefined,
+ extraResponse = undefined,
+ } = {}) {
+ let histograms = {
+ latency: [
+ lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_LATENCY),
+ ],
+ response: [
+ lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_RESPONSE),
+ ],
+ };
+ if (extraLatency) {
+ histograms.latency.push(
+ lazy.TelemetryTestUtils.getAndClearHistogram(extraLatency)
+ );
+ }
+ if (extraResponse) {
+ histograms.response.push(
+ lazy.TelemetryTestUtils.getAndClearHistogram(extraResponse)
+ );
+ }
+ return histograms;
+ }
+
+ /**
+ * Asserts the Merino-related histograms are updated as expected. Clears the
+ * histograms before returning.
+ *
+ * @param {object} options
+ * Options object
+ * @param {MerinoClient} options.client
+ * The relevant `MerinoClient` instance. This is used to check the latency
+ * stopwatch.
+ * @param {object} options.histograms
+ * The histograms object returned from `getAndClearHistograms()`.
+ * @param {string} options.response
+ * The expected string label for the `response` histogram. If the histogram
+ * should not be recorded, pass null.
+ * @param {boolean} options.latencyRecorded
+ * Whether the latency histogram is expected to contain a value.
+ * @param {boolean} options.latencyStopwatchRunning
+ * Whether the latency stopwatch is expected to be running.
+ */
+ checkAndClearHistograms({
+ client,
+ histograms,
+ response,
+ latencyRecorded,
+ latencyStopwatchRunning = false,
+ }) {
+ // Check the response histograms.
+ if (response) {
+ this.Assert.ok(
+ RESPONSE_HISTOGRAM_VALUES.hasOwnProperty(response),
+ "Sanity check: Expected response is valid: " + response
+ );
+ for (let histogram of histograms.response) {
+ lazy.TelemetryTestUtils.assertHistogram(
+ histogram,
+ RESPONSE_HISTOGRAM_VALUES[response],
+ 1
+ );
+ }
+ } else {
+ for (let histogram of histograms.response) {
+ this.Assert.strictEqual(
+ histogram.snapshot().sum,
+ 0,
+ "Response histogram not updated: " + histogram.name()
+ );
+ }
+ }
+
+ // Check the latency histograms.
+ if (latencyRecorded) {
+ // There should be a single value across all buckets.
+ for (let histogram of histograms.latency) {
+ this.Assert.deepEqual(
+ Object.values(histogram.snapshot().values).filter(v => v > 0),
+ [1],
+ "Latency histogram updated: " + histogram.name()
+ );
+ }
+ } else {
+ for (let histogram of histograms.latency) {
+ this.Assert.strictEqual(
+ histogram.snapshot().sum,
+ 0,
+ "Latency histogram not updated: " + histogram.name()
+ );
+ }
+ }
+
+ // Check the latency stopwatch.
+ this.Assert.equal(
+ TelemetryStopwatch.running(
+ HISTOGRAM_LATENCY,
+ client._test_latencyStopwatchInstance
+ ),
+ latencyStopwatchRunning,
+ "Latency stopwatch running as expected"
+ );
+
+ // Clear histograms.
+ for (let histogramArray of Object.values(histograms)) {
+ for (let histogram of histogramArray) {
+ histogram.clear();
+ }
+ }
+ }
+
+ /**
+ * Initializes the quick suggest weather feature and mock Merino server.
+ */
+ async initWeather() {
+ await this.server.start();
+ this.server.response.body.suggestions = [WEATHER_SUGGESTION];
+
+ lazy.QuickSuggest.weather._test_fetchIntervalMs = WEATHER_FETCH_INTERVAL_MS;
+
+ // Enabling weather will trigger a fetch. Wait for it to finish so the
+ // suggestion is ready when this function returns.
+ let fetchPromise = lazy.QuickSuggest.weather.waitForFetches();
+ lazy.UrlbarPrefs.set("weather.featureGate", true);
+ lazy.UrlbarPrefs.set("suggest.weather", true);
+ await fetchPromise;
+
+ this.Assert.equal(
+ lazy.QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "No pending fetches after awaiting initial fetch"
+ );
+
+ this.registerCleanupFunction?.(async () => {
+ lazy.UrlbarPrefs.clear("weather.featureGate");
+ lazy.UrlbarPrefs.clear("suggest.weather");
+ lazy.QuickSuggest.weather._test_fetchIntervalMs = -1;
+ });
+ }
+
+ #initDepth = 0;
+ #server = null;
+}
+
+/**
+ * A mock Merino server with useful helper methods.
+ */
+class MockMerinoServer {
+ /**
+ * Until `start()` is called the server isn't started and `this.url` is null.
+ *
+ * @param {object} scope
+ * The global JS scope where tests are being run. This allows the instance
+ * to access test helpers like `Assert` that are available in the scope.
+ */
+ constructor(scope) {
+ scope.info?.("MockMerinoServer constructor");
+
+ for (let p of TEST_SCOPE_PROPERTIES) {
+ this[p] = scope[p];
+ }
+
+ let path = "/merino";
+ this.#httpServer = new HttpServer();
+ this.#httpServer.registerPathHandler(path, (req, resp) =>
+ this.#handleRequest(req, resp)
+ );
+ this.#baseURL = new URL("http://localhost/");
+ this.#baseURL.pathname = path;
+
+ this.reset();
+ }
+
+ /**
+ * Uninitializes the server.
+ */
+ uninit() {
+ this.info?.("MockMerinoServer uninit");
+ for (let p of TEST_SCOPE_PROPERTIES) {
+ this[p] = null;
+ }
+ }
+
+ /**
+ * @returns {nsIHttpServer}
+ * The underlying HTTP server.
+ */
+ get httpServer() {
+ return this.#httpServer;
+ }
+
+ /**
+ * @returns {URL}
+ * The server's endpoint URL or null if the server isn't running.
+ */
+ get url() {
+ return this.#url;
+ }
+
+ /**
+ * @returns {Array}
+ * Array of received nsIHttpRequest objects. Requests are continually
+ * collected, and the list can be cleared with `reset()`.
+ */
+ get requests() {
+ return this.#requests;
+ }
+
+ /**
+ * @returns {object}
+ * An object that describes the response that the server will return. Can be
+ * modified or set to a different object to change the response. Can be
+ * reset to the default reponse by calling `reset()`. For details see
+ * `makeDefaultResponse()` and `#handleRequest()`. In summary:
+ *
+ * {
+ * status,
+ * contentType,
+ * delay,
+ * body: {
+ * request_id,
+ * suggestions,
+ * },
+ * }
+ */
+ get response() {
+ return this.#response;
+ }
+ set response(value) {
+ this.#response = value;
+ }
+
+ /**
+ * Starts the server and sets `this.url`. If the server was created with a
+ * test scope that defines `registerCleanupFunction()`, you don't need to call
+ * `stop()` yourself because it will automatically be called as a cleanup
+ * function. Otherwise you'll need to call `stop()`.
+ */
+ async start() {
+ if (this.#url) {
+ return;
+ }
+
+ this.info("MockMerinoServer starting");
+
+ this.#httpServer.start(-1);
+ this.#url = new URL(this.#baseURL);
+ this.#url.port = this.#httpServer.identity.primaryPort;
+
+ this._originalEndpointURL = lazy.UrlbarPrefs.get("merino.endpointURL");
+ lazy.UrlbarPrefs.set("merino.endpointURL", this.#url.toString());
+
+ this.registerCleanupFunction?.(() => this.stop());
+
+ // Wait for the server to actually start serving. In TV tests, where the
+ // server is created over and over again, sometimes it doesn't seem to be
+ // ready after being recreated even after `#httpServer.start()` is called.
+ this.info("MockMerinoServer waiting to start serving...");
+ this.reset();
+ let suggestion;
+ while (!suggestion) {
+ let response = await fetch(this.#url);
+ let body = await response?.json();
+ suggestion = body?.suggestions?.[0];
+ }
+ this.reset();
+ this.info("MockMerinoServer is now serving");
+ }
+
+ /**
+ * Stops the server and cleans up other state.
+ */
+ async stop() {
+ if (!this.#url) {
+ return;
+ }
+
+ // `uninit()` may have already been called by this point and removed
+ // `this.info()`, so don't assume it's defined.
+ this.info?.("MockMerinoServer stopping");
+
+ // Cancel delayed-response timers and resolve their promises. Otherwise, if
+ // a test awaits this method before finishing, it will hang until the timers
+ // fire and allow the server to send the responses.
+ this.#cancelDelayedResponses();
+
+ await this.#httpServer.stop();
+ this.#url = null;
+ lazy.UrlbarPrefs.set("merino.endpointURL", this._originalEndpointURL);
+
+ this.info?.("MockMerinoServer is now stopped");
+ }
+
+ /**
+ * Returns a new object that describes the default response the server will
+ * return.
+ *
+ * @returns {object}
+ */
+ makeDefaultResponse() {
+ return {
+ status: 200,
+ contentType: "application/json",
+ body: {
+ request_id: "request_id",
+ suggestions: [
+ {
+ provider: "adm",
+ full_keyword: "full_keyword",
+ title: "title",
+ url: "url",
+ icon: null,
+ impression_url: "impression_url",
+ click_url: "click_url",
+ block_id: 1,
+ advertiser: "advertiser",
+ is_sponsored: true,
+ score: 1,
+ },
+ ],
+ },
+ };
+ }
+
+ /**
+ * Clears the received requests and sets the response to the default.
+ */
+ reset() {
+ this.#requests = [];
+ this.response = this.makeDefaultResponse();
+ this.#cancelDelayedResponses();
+ }
+
+ /**
+ * Asserts a given list of requests has been received. Clears the list of
+ * received requests before returning.
+ *
+ * @param {Array} expected
+ * The expected requests. Each item should be an object: `{ params }`
+ */
+ checkAndClearRequests(expected) {
+ let actual = this.requests.map(req => {
+ let params = new URLSearchParams(req.queryString);
+ return { params: Object.fromEntries(params) };
+ });
+
+ this.info("Checking requests");
+ this.info("actual: " + JSON.stringify(actual));
+ this.info("expect: " + JSON.stringify(expected));
+
+ // Check the request count.
+ this.Assert.equal(actual.length, expected.length, "Expected request count");
+ if (actual.length != expected.length) {
+ return;
+ }
+
+ // Check each request.
+ for (let i = 0; i < actual.length; i++) {
+ let a = actual[i];
+ let e = expected[i];
+ this.info("Checking requests at index " + i);
+ this.info("actual: " + JSON.stringify(a));
+ this.info("expect: " + JSON.stringify(e));
+
+ // Check required search params.
+ for (let p of REQUIRED_SEARCH_PARAMS) {
+ this.Assert.ok(
+ a.params.hasOwnProperty(p),
+ "Required param is present in actual request: " + p
+ );
+ if (p != SEARCH_PARAMS.SESSION_ID) {
+ this.Assert.ok(
+ e.params.hasOwnProperty(p),
+ "Required param is present in expected request: " + p
+ );
+ }
+ }
+
+ // If the expected request doesn't include a session ID, then:
+ if (!e.params.hasOwnProperty(SEARCH_PARAMS.SESSION_ID)) {
+ if (e.params[SEARCH_PARAMS.SEQUENCE_NUMBER] == 0 || i == 0) {
+ // If its sequence number is zero, then copy the actual request's
+ // sequence number to the expected request. As a convenience, do the
+ // same if this is the first request.
+ e.params[SEARCH_PARAMS.SESSION_ID] =
+ a.params[SEARCH_PARAMS.SESSION_ID];
+ } else {
+ // Otherwise this is not the first request in the session and
+ // therefore the session ID should be the same as the ID in the
+ // previous expected request.
+ e.params[SEARCH_PARAMS.SESSION_ID] =
+ expected[i - 1].params[SEARCH_PARAMS.SESSION_ID];
+ }
+ }
+
+ this.Assert.deepEqual(a, e, "Expected request at index " + i);
+
+ let actualSessionID = a.params[SEARCH_PARAMS.SESSION_ID];
+ this.Assert.ok(actualSessionID, "Session ID exists");
+ this.Assert.ok(
+ /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(actualSessionID),
+ "Session ID is a UUID"
+ );
+ }
+
+ this.#requests = [];
+ }
+
+ /**
+ * Temporarily creates the conditions for a network error. Any Merino fetches
+ * that occur during the callback will fail with a network error.
+ *
+ * @param {Function} callback
+ * Callback function.
+ */
+ async withNetworkError(callback) {
+ // Set the endpoint to a valid, unreachable URL.
+ let originalURL = lazy.UrlbarPrefs.get("merino.endpointURL");
+ lazy.UrlbarPrefs.set(
+ "merino.endpointURL",
+ "http://localhost/valid-but-unreachable-url"
+ );
+
+ // Set the timeout high enough that the network error exception will happen
+ // first. On Mac and Linux the fetch naturally times out fairly quickly but
+ // on Windows it seems to take 5s, so set our artificial timeout to 10s.
+ let originalTimeout = lazy.UrlbarPrefs.get("merino.timeoutMs");
+ lazy.UrlbarPrefs.set("merino.timeoutMs", 10000);
+
+ await callback();
+
+ lazy.UrlbarPrefs.set("merino.endpointURL", originalURL);
+ lazy.UrlbarPrefs.set("merino.timeoutMs", originalTimeout);
+ }
+
+ /**
+ * Returns a promise that will resolve when the next request is received.
+ *
+ * @returns {Promise}
+ */
+ waitForNextRequest() {
+ if (!this.#nextRequestDeferred) {
+ this.#nextRequestDeferred = lazy.PromiseUtils.defer();
+ }
+ return this.#nextRequestDeferred.promise;
+ }
+
+ /**
+ * nsIHttpServer request handler.
+ *
+ * @param {nsIHttpRequest} httpRequest
+ * Request.
+ * @param {nsIHttpResponse} httpResponse
+ * Response.
+ */
+ #handleRequest(httpRequest, httpResponse) {
+ this.info(
+ "MockMerinoServer received request with query string: " +
+ JSON.stringify(httpRequest.queryString)
+ );
+ this.info(
+ "MockMerinoServer replying with response: " +
+ JSON.stringify(this.response)
+ );
+
+ // Add the request to the list of received requests.
+ this.#requests.push(httpRequest);
+
+ // Resolve promises waiting on the next request.
+ this.#nextRequestDeferred?.resolve();
+ this.#nextRequestDeferred = null;
+
+ // Now set up and finish the response.
+ httpResponse.processAsync();
+
+ let { response } = this;
+
+ let finishResponse = () => {
+ let status = response.status || 200;
+ httpResponse.setStatusLine("", status, status);
+
+ let contentType = response.contentType || "application/json";
+ httpResponse.setHeader("Content-Type", contentType, false);
+
+ if (typeof response.body == "string") {
+ httpResponse.write(response.body);
+ } else if (response.body) {
+ httpResponse.write(JSON.stringify(response.body));
+ }
+
+ httpResponse.finish();
+ };
+
+ if (typeof response.delay != "number") {
+ finishResponse();
+ return;
+ }
+
+ // Set up a timer to wait until the delay elapses. Since we called
+ // `httpResponse.processAsync()`, we need to be careful to always finish the
+ // response, even if the timer is canceled. Otherwise the server will hang
+ // when we try to stop it at the end of the test. When an `nsITimer` is
+ // canceled, its callback is *not* called. Therefore we set up a race
+ // between the timer's callback and a deferred promise. If the timer is
+ // canceled, resolving the deferred promise will resolve the race, and the
+ // response can then be finished.
+
+ let delayedResponseID = this.#nextDelayedResponseID++;
+ this.info(
+ "MockMerinoServer delaying response: " +
+ JSON.stringify({ delayedResponseID, delay: response.delay })
+ );
+
+ let deferred = lazy.PromiseUtils.defer();
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ let record = { timer, resolve: deferred.resolve };
+ this.#delayedResponseRecords.add(record);
+
+ // Don't await this promise.
+ Promise.race([
+ deferred.promise,
+ new Promise(resolve => {
+ timer.initWithCallback(
+ resolve,
+ response.delay,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ }),
+ ]).then(() => {
+ this.info(
+ "MockMerinoServer done delaying response: " +
+ JSON.stringify({ delayedResponseID })
+ );
+ deferred.resolve();
+ this.#delayedResponseRecords.delete(record);
+ finishResponse();
+ });
+ }
+
+ /**
+ * Cancels the timers for delayed responses and resolves their promises.
+ */
+ #cancelDelayedResponses() {
+ for (let { timer, resolve } of this.#delayedResponseRecords) {
+ timer.cancel();
+ resolve();
+ }
+ this.#delayedResponseRecords.clear();
+ }
+
+ #httpServer = null;
+ #url = null;
+ #baseURL = null;
+ #response = null;
+ #requests = [];
+ #nextRequestDeferred = null;
+ #nextDelayedResponseID = 0;
+ #delayedResponseRecords = new Set();
+}
+
+export var MerinoTestUtils = new _MerinoTestUtils();
diff --git a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs
new file mode 100644
index 0000000000..34da73e847
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs
@@ -0,0 +1,1017 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable mozilla/valid-lazy */
+
+import {
+ CONTEXTUAL_SERVICES_PING_TYPES,
+ PartnerLinkAttribution,
+} from "resource:///modules/PartnerLinkAttribution.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ QuickSuggestRemoteSettings:
+ "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ TestUtils: "resource://testing-common/TestUtils.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarProviderQuickSuggest:
+ "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
+ UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+let gTestScope;
+
+// Test utils singletons need special handling. Since they are uninitialized in
+// cleanup functions, they must be re-initialized on each new test. That does
+// not happen automatically inside system modules like this one because system
+// module lifetimes are the app's lifetime, unlike individual browser chrome and
+// xpcshell tests.
+Object.defineProperty(lazy, "UrlbarTestUtils", {
+ get: () => {
+ if (!lazy._UrlbarTestUtils) {
+ const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlbarTestUtils.sys.mjs"
+ );
+ module.init(gTestScope);
+ gTestScope.registerCleanupFunction(() => {
+ // Make sure the utils are re-initialized during the next test.
+ lazy._UrlbarTestUtils = null;
+ });
+ lazy._UrlbarTestUtils = module;
+ }
+ return lazy._UrlbarTestUtils;
+ },
+});
+
+// Test utils singletons need special handling. Since they are uninitialized in
+// cleanup functions, they must be re-initialized on each new test. That does
+// not happen automatically inside system modules like this one because system
+// module lifetimes are the app's lifetime, unlike individual browser chrome and
+// xpcshell tests.
+Object.defineProperty(lazy, "MerinoTestUtils", {
+ get: () => {
+ if (!lazy._MerinoTestUtils) {
+ const { MerinoTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/MerinoTestUtils.sys.mjs"
+ );
+ module.init(gTestScope);
+ gTestScope.registerCleanupFunction(() => {
+ // Make sure the utils are re-initialized during the next test.
+ lazy._MerinoTestUtils = null;
+ });
+ lazy._MerinoTestUtils = module;
+ }
+ return lazy._MerinoTestUtils;
+ },
+});
+
+const DEFAULT_CONFIG = {};
+
+const BEST_MATCH_CONFIG = {
+ best_match: {
+ blocked_suggestion_ids: [],
+ min_search_string_length: 4,
+ },
+};
+
+const DEFAULT_PING_PAYLOADS = {
+ [CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK]: {
+ advertiser: "testadvertiser",
+ block_id: 1,
+ context_id: () => actual => !!actual,
+ iab_category: "22 - Shopping",
+ improve_suggest_experience_checked: false,
+ match_type: "firefox-suggest",
+ request_id: null,
+ source: "remote-settings",
+ },
+ [CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION]: {
+ advertiser: "testadvertiser",
+ block_id: 1,
+ context_id: () => actual => !!actual,
+ improve_suggest_experience_checked: false,
+ match_type: "firefox-suggest",
+ reporting_url: "https://example.com/click",
+ request_id: null,
+ source: "remote-settings",
+ },
+ [CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION]: {
+ advertiser: "testadvertiser",
+ block_id: 1,
+ context_id: () => actual => !!actual,
+ improve_suggest_experience_checked: false,
+ is_clicked: false,
+ match_type: "firefox-suggest",
+ reporting_url: "https://example.com/impression",
+ request_id: null,
+ source: "remote-settings",
+ },
+};
+
+// The following properties and methods are copied from the test scope to the
+// test utils object so they can be easily accessed. Be careful about assuming a
+// particular property will be defined because depending on the scope -- browser
+// test or xpcshell test -- some may not be.
+const TEST_SCOPE_PROPERTIES = [
+ "Assert",
+ "EventUtils",
+ "info",
+ "registerCleanupFunction",
+];
+
+/**
+ * Mock RemoteSettings.
+ *
+ * @param {object} options
+ * Options object
+ * @param {object} options.config
+ * Dummy config in the RemoteSettings.
+ * @param {Array} options.data
+ * Dummy data in the RemoteSettings.
+ */
+class MockRemoteSettings {
+ constructor({ config = DEFAULT_CONFIG, data = [] }) {
+ this.#config = config;
+ this.#data = data;
+
+ // Make a stub for "get" function to return dummy data.
+ const rs = lazy.RemoteSettings("quicksuggest");
+ this.#sandbox = lazy.sinon.createSandbox();
+ this.#sandbox.stub(rs, "get").callsFake(async query => {
+ return query.filters.type === "configuration"
+ ? [{ configuration: this.#config }]
+ : this.#data.filter(r => r.type === query.filters.type);
+ });
+
+ // Make a stub for "download" in attachments.
+ this.#sandbox.stub(rs.attachments, "download").callsFake(async record => {
+ if (!record.attachment) {
+ throw new Error("No attachmet in the record");
+ }
+ const encoder = new TextEncoder();
+ return {
+ buffer: encoder.encode(JSON.stringify(record.attachment)),
+ };
+ });
+ }
+
+ async sync() {
+ if (!lazy.QuickSuggestRemoteSettings.rs) {
+ // There are no registered features that use remote settings.
+ return;
+ }
+
+ // Observe config-set event to recognize that the config is synced.
+ const onConfigSync = new Promise(resolve => {
+ lazy.QuickSuggestRemoteSettings.emitter.once("config-set", resolve);
+ });
+
+ // Make a stub for each feature to recognize that the features are synced.
+ const features = lazy.QuickSuggestRemoteSettings.features;
+ const onFeatureSyncs = features.map(feature => {
+ return new Promise(resolve => {
+ const stub = this.#sandbox
+ .stub(feature, "onRemoteSettingsSync")
+ .callsFake(async (...args) => {
+ // Call and wait for the original function.
+ await stub.wrappedMethod.apply(feature, args);
+ stub.restore();
+ resolve();
+ });
+ });
+ });
+
+ // Force to sync.
+ const rs = lazy.RemoteSettings("quicksuggest");
+ rs.emit("sync");
+
+ // Wait for sync.
+ await Promise.all([onConfigSync, ...onFeatureSyncs]);
+ }
+
+ /*
+ * Update the config and data in RemoteSettings. If the config or the data are
+ * undefined, use the current one.
+ *
+ * @param {object} options
+ * Options object
+ * @param {object} options.config
+ * Dummy config in the RemoteSettings.
+ * @param {Array} options.data
+ * Dummy data in the RemoteSettings.
+ */
+ async update({ config = this.#config, data = this.#data }) {
+ this.#config = config;
+ this.#data = data;
+
+ await this.sync();
+ }
+
+ cleanup() {
+ this.#sandbox.restore();
+ }
+
+ #config = null;
+ #data = null;
+ #sandbox = null;
+}
+
+/**
+ * Test utils for quick suggest.
+ */
+class _QuickSuggestTestUtils {
+ /**
+ * Initializes the utils.
+ *
+ * @param {object} scope
+ * The global JS scope where tests are being run. This allows the instance
+ * to access test helpers like `Assert` that are available in the scope.
+ */
+ init(scope) {
+ if (!scope) {
+ throw new Error("QuickSuggestTestUtils() must be called with a scope");
+ }
+ gTestScope = scope;
+ for (let p of TEST_SCOPE_PROPERTIES) {
+ this[p] = scope[p];
+ }
+ // If you add other properties to `this`, null them in `uninit()`.
+
+ Services.telemetry.clearScalars();
+
+ scope.registerCleanupFunction?.(() => this.uninit());
+ }
+
+ /**
+ * Uninitializes the utils. If they were created with a test scope that
+ * defines `registerCleanupFunction()`, you don't need to call this yourself
+ * because it will automatically be called as a cleanup function. Otherwise
+ * you'll need to call this.
+ */
+ uninit() {
+ gTestScope = null;
+ for (let p of TEST_SCOPE_PROPERTIES) {
+ this[p] = null;
+ }
+ Services.telemetry.clearScalars();
+ }
+
+ get DEFAULT_CONFIG() {
+ // Return a clone so callers can modify it.
+ return Cu.cloneInto(DEFAULT_CONFIG, this);
+ }
+
+ get BEST_MATCH_CONFIG() {
+ // Return a clone so callers can modify it.
+ return Cu.cloneInto(BEST_MATCH_CONFIG, this);
+ }
+
+ /**
+ * Waits for quick suggest initialization to finish, ensures its data will not
+ * be updated again during the test, and also optionally sets it up with mock
+ * suggestions.
+ *
+ * @param {object} options
+ * Options object
+ * @param {Array} options.remoteSettingsResults
+ * Array of remote settings result objects. If not given, no suggestions
+ * will be present in remote settings.
+ * @param {Array} options.merinoSuggestions
+ * Array of Merino suggestion objects. If given, this function will start
+ * the mock Merino server and set `quicksuggest.dataCollection.enabled` to
+ * true so that `UrlbarProviderQuickSuggest` will fetch suggestions from it.
+ * Otherwise Merino will not serve suggestions, but you can still set up
+ * Merino without using this function by using `MerinoTestUtils` directly.
+ * @param {object} options.config
+ * The quick suggest configuration object.
+ * @returns {Function}
+ * A cleanup function. You only need to call this function if you're in a
+ * browser chrome test and you did not also call `init`. You can ignore it
+ * otherwise.
+ */
+ async ensureQuickSuggestInit({
+ remoteSettingsResults,
+ merinoSuggestions = null,
+ config = DEFAULT_CONFIG,
+ } = {}) {
+ this.#mockRemoteSettings = new MockRemoteSettings({
+ config,
+ data: remoteSettingsResults,
+ });
+
+ this.info?.("ensureQuickSuggestInit calling QuickSuggest.init()");
+ lazy.QuickSuggest.init();
+
+ // Sync with current data.
+ await this.#mockRemoteSettings.sync();
+
+ // Set up Merino.
+ if (merinoSuggestions) {
+ this.info?.("ensureQuickSuggestInit setting up Merino server");
+ await lazy.MerinoTestUtils.server.start();
+ lazy.MerinoTestUtils.server.response.body.suggestions = merinoSuggestions;
+ lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true);
+ this.info?.("ensureQuickSuggestInit done setting up Merino server");
+ }
+
+ let cleanup = async () => {
+ this.info?.("ensureQuickSuggestInit starting cleanup");
+ this.#mockRemoteSettings.cleanup();
+ if (merinoSuggestions) {
+ lazy.UrlbarPrefs.clear("quicksuggest.dataCollection.enabled");
+ }
+ this.info?.("ensureQuickSuggestInit finished cleanup");
+ };
+ this.registerCleanupFunction?.(cleanup);
+
+ return cleanup;
+ }
+
+ /**
+ * Clears the current remote settings data and adds a new set of data.
+ * This can be used to add remote settings data after
+ * `ensureQuickSuggestInit()` has been called.
+ *
+ * @param {Array} data
+ * Array of remote settings data objects.
+ */
+ async setRemoteSettingsResults(data) {
+ await this.#mockRemoteSettings.update({ data });
+ }
+
+ /**
+ * Sets the quick suggest configuration. You should call this again with
+ * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`.
+ *
+ * @param {object} config
+ * The config to be applied. See
+ */
+ async setConfig(config) {
+ await this.#mockRemoteSettings.update({ config });
+ }
+
+ /**
+ * Sets the quick suggest configuration, calls your callback, and restores the
+ * previous configuration.
+ *
+ * @param {object} options
+ * The options object.
+ * @param {object} options.config
+ * The configuration that should be used with the callback
+ * @param {Function} options.callback
+ * Will be called with the configuration applied
+ *
+ * @see {@link setConfig}
+ */
+ async withConfig({ config, callback }) {
+ let original = lazy.QuickSuggestRemoteSettings.config;
+ await this.setConfig(config);
+ await callback();
+ await this.setConfig(original);
+ }
+
+ /**
+ * Sets the Firefox Suggest scenario and waits for prefs to be updated.
+ *
+ * @param {string} scenario
+ * Pass falsey to reset the scenario to the default.
+ */
+ async setScenario(scenario) {
+ // If we try to set the scenario before a previous update has finished,
+ // `updateFirefoxSuggestScenario` will bail, so wait.
+ await this.waitForScenarioUpdated();
+ await lazy.UrlbarPrefs.updateFirefoxSuggestScenario({ scenario });
+ }
+
+ /**
+ * Waits for any prior scenario update to finish.
+ */
+ async waitForScenarioUpdated() {
+ await lazy.TestUtils.waitForCondition(
+ () => !lazy.UrlbarPrefs.updatingFirefoxSuggestScenario,
+ "Waiting for updatingFirefoxSuggestScenario to be false"
+ );
+ }
+
+ /**
+ * Asserts a result is a quick suggest result.
+ *
+ * @param {object} [options]
+ * The options object.
+ * @param {string} options.url
+ * The expected URL. At least one of `url` and `originalUrl` must be given.
+ * @param {string} options.originalUrl
+ * The expected original URL (the URL with an unreplaced timestamp
+ * template). At least one of `url` and `originalUrl` must be given.
+ * @param {object} options.window
+ * The window that should be used for this assertion
+ * @param {number} [options.index]
+ * The expected index of the quick suggest result. Pass -1 to use the index
+ * of the last result.
+ * @param {boolean} [options.isSponsored]
+ * Whether the result is expected to be sponsored.
+ * @param {boolean} [options.isBestMatch]
+ * Whether the result is expected to be a best match.
+ * @returns {result}
+ * The quick suggest result.
+ */
+ async assertIsQuickSuggest({
+ url,
+ originalUrl,
+ window,
+ index = -1,
+ isSponsored = true,
+ isBestMatch = false,
+ } = {}) {
+ this.Assert.ok(
+ url || originalUrl,
+ "At least one of url and originalUrl is specified"
+ );
+
+ if (index < 0) {
+ let resultCount = lazy.UrlbarTestUtils.getResultCount(window);
+ if (isBestMatch) {
+ index = 1;
+ this.Assert.greater(
+ resultCount,
+ 1,
+ "Sanity check: Result count should be > 1"
+ );
+ } else {
+ index = resultCount - 1;
+ this.Assert.greater(
+ resultCount,
+ 0,
+ "Sanity check: Result count should be > 0"
+ );
+ }
+ }
+
+ let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ index
+ );
+ let { result } = details;
+
+ this.info?.(
+ `Checking actual result at index ${index}: ` + JSON.stringify(result)
+ );
+
+ this.Assert.equal(
+ result.providerName,
+ "UrlbarProviderQuickSuggest",
+ "Result provider name is UrlbarProviderQuickSuggest"
+ );
+ this.Assert.equal(details.type, lazy.UrlbarUtils.RESULT_TYPE.URL);
+ this.Assert.equal(details.isSponsored, isSponsored, "Result isSponsored");
+ if (url) {
+ this.Assert.equal(details.url, url, "Result URL");
+ }
+ if (originalUrl) {
+ this.Assert.equal(
+ result.payload.originalUrl,
+ originalUrl,
+ "Result original URL"
+ );
+ }
+
+ this.Assert.equal(!!result.isBestMatch, isBestMatch, "Result isBestMatch");
+
+ let { row } = details.element;
+
+ let sponsoredElement = isBestMatch
+ ? row._elements.get("bottom")
+ : row._elements.get("action");
+ this.Assert.ok(sponsoredElement, "Result sponsored label element exists");
+ this.Assert.equal(
+ sponsoredElement.textContent,
+ isSponsored ? "Sponsored" : "",
+ "Result sponsored label"
+ );
+
+ this.Assert.equal(
+ result.payload.helpUrl,
+ lazy.QuickSuggest.HELP_URL,
+ "Result helpURL"
+ );
+
+ if (lazy.UrlbarPrefs.get("resultMenu")) {
+ this.Assert.ok(
+ row._buttons.get("menu"),
+ "The menu button should be present"
+ );
+ } else {
+ let helpButton = row._buttons.get("help");
+ this.Assert.ok(helpButton, "The help button should be present");
+
+ let blockButton = row._buttons.get("block");
+ if (!isBestMatch) {
+ this.Assert.equal(
+ !!blockButton,
+ lazy.UrlbarPrefs.get("quickSuggestBlockingEnabled"),
+ "The block button is present iff quick suggest blocking is enabled"
+ );
+ } else {
+ this.Assert.equal(
+ !!blockButton,
+ lazy.UrlbarPrefs.get("bestMatchBlockingEnabled"),
+ "The block button is present iff best match blocking is enabled"
+ );
+ }
+ }
+
+ return details;
+ }
+
+ /**
+ * Asserts a result is not a quick suggest result.
+ *
+ * @param {object} window
+ * The window that should be used for this assertion
+ * @param {number} index
+ * The index of the result.
+ */
+ async assertIsNotQuickSuggest(window, index) {
+ let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ index
+ );
+ this.Assert.notEqual(
+ details.result.providerName,
+ "UrlbarProviderQuickSuggest",
+ `Result at index ${index} is not provided by UrlbarProviderQuickSuggest`
+ );
+ }
+
+ /**
+ * Asserts that none of the results are quick suggest results.
+ *
+ * @param {object} window
+ * The window that should be used for this assertion
+ */
+ async assertNoQuickSuggestResults(window) {
+ for (let i = 0; i < lazy.UrlbarTestUtils.getResultCount(window); i++) {
+ await this.assertIsNotQuickSuggest(window, i);
+ }
+ }
+
+ /**
+ * Checks the values of all the quick suggest telemetry keyed scalars and,
+ * if provided, other non-quick-suggest keyed scalars. Scalar values are all
+ * assumed to be 1.
+ *
+ * @param {object} expectedKeysByScalarName
+ * Maps scalar names to keys that are expected to be recorded. The value for
+ * each key is assumed to be 1. If you expect a scalar to be incremented,
+ * include it in this object; otherwise, don't include it.
+ */
+ assertScalars(expectedKeysByScalarName) {
+ let scalars = lazy.TelemetryTestUtils.getProcessScalars(
+ "parent",
+ true,
+ true
+ );
+
+ // Check all quick suggest scalars.
+ expectedKeysByScalarName = { ...expectedKeysByScalarName };
+ for (let scalarName of Object.values(
+ lazy.UrlbarProviderQuickSuggest.TELEMETRY_SCALARS
+ )) {
+ if (scalarName in expectedKeysByScalarName) {
+ lazy.TelemetryTestUtils.assertKeyedScalar(
+ scalars,
+ scalarName,
+ expectedKeysByScalarName[scalarName],
+ 1
+ );
+ delete expectedKeysByScalarName[scalarName];
+ } else {
+ this.Assert.ok(
+ !(scalarName in scalars),
+ "Scalar should not be present: " + scalarName
+ );
+ }
+ }
+
+ // Check any other remaining scalars that were passed in.
+ for (let [scalarName, key] of Object.entries(expectedKeysByScalarName)) {
+ lazy.TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, key, 1);
+ }
+ }
+
+ /**
+ * Checks quick suggest telemetry events. This is the same as
+ * `TelemetryTestUtils.assertEvents()` except it filters in only quick suggest
+ * events by default. If you are expecting events that are not in the quick
+ * suggest category, use `TelemetryTestUtils.assertEvents()` directly or pass
+ * in a filter override for `category`.
+ *
+ * @param {Array} expectedEvents
+ * List of expected telemetry events.
+ * @param {object} filterOverrides
+ * Extra properties to set in the filter object.
+ * @param {object} options
+ * The options object to pass to `TelemetryTestUtils.assertEvents()`.
+ */
+ assertEvents(expectedEvents, filterOverrides = {}, options = undefined) {
+ lazy.TelemetryTestUtils.assertEvents(
+ expectedEvents,
+ {
+ category: lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ ...filterOverrides,
+ },
+ options
+ );
+ }
+
+ /**
+ * Creates a `sinon.sandbox` and `sinon.spy` that can be used to instrument
+ * the quick suggest custom telemetry pings. If `init` was called with a test
+ * scope where `registerCleanupFunction` is defined, the sandbox will
+ * automically be restored at the end of the test.
+ *
+ * @returns {object}
+ * An object: { sandbox, spy, spyCleanup }
+ * `spyCleanup` is a cleanup function that should be called if you're in a
+ * browser chrome test and you did not also call `init`, or if you need to
+ * remove the spy before the test ends for some other reason. You can ignore
+ * it otherwise.
+ */
+ createTelemetryPingSpy() {
+ let sandbox = lazy.sinon.createSandbox();
+ let spy = sandbox.spy(
+ PartnerLinkAttribution._pingCentre,
+ "sendStructuredIngestionPing"
+ );
+ let spyCleanup = () => sandbox.restore();
+ this.registerCleanupFunction?.(spyCleanup);
+ return { sandbox, spy, spyCleanup };
+ }
+
+ /**
+ * Asserts that custom telemetry pings are recorded in the order they appear
+ * in the given `pings` array and that no other pings are recorded.
+ *
+ * @param {object} spy
+ * A `sinon.spy` object. See `createTelemetryPingSpy()`. This method resets
+ * the spy before returning.
+ * @param {Array} pings
+ * The expected pings in the order they are expected to be recorded. Each
+ * item in this array should be an object: `{ type, payload }`
+ *
+ * {string} type
+ * The ping's expected type, one of the `CONTEXTUAL_SERVICES_PING_TYPES`
+ * values.
+ * {object} payload
+ * The ping's expected payload. For convenience, you can leave out
+ * properties whose values are expected to be the default values defined
+ * in `DEFAULT_PING_PAYLOADS`.
+ */
+ assertPings(spy, pings) {
+ let calls = spy.getCalls();
+ this.Assert.equal(
+ calls.length,
+ pings.length,
+ "Expected number of ping calls"
+ );
+
+ for (let i = 0; i < pings.length; i++) {
+ let ping = pings[i];
+ this.info?.(
+ `Checking ping at index ${i}, expected is: ` + JSON.stringify(ping)
+ );
+
+ // Add default properties to the expected payload for any that aren't
+ // already defined.
+ let { type, payload } = ping;
+ let defaultPayload = DEFAULT_PING_PAYLOADS[type];
+ this.Assert.ok(
+ defaultPayload,
+ `Sanity check: Default payload exists for type: ${type}`
+ );
+ payload = { ...defaultPayload, ...payload };
+
+ // Check the endpoint URL.
+ let call = calls[i];
+ let endpointURL = call.args[1];
+ this.Assert.ok(
+ endpointURL.includes(type),
+ `Endpoint URL corresponds to the expected ping type: ${type}`
+ );
+
+ // Check the payload.
+ let actualPayload = call.args[0];
+ this._assertPingPayload(actualPayload, payload);
+ }
+
+ spy.resetHistory();
+ }
+
+ /**
+ * Helper for checking contextual services ping payloads.
+ *
+ * @param {object} actualPayload
+ * The actual payload in the ping.
+ * @param {object} expectedPayload
+ * An object describing the expected payload. Non-function values in this
+ * object are checked for equality against the corresponding actual payload
+ * values. Function values are called and passed the corresponding actual
+ * values and should return true if the actual values are correct.
+ */
+ _assertPingPayload(actualPayload, expectedPayload) {
+ this.info?.(
+ "Checking ping payload. Actual: " +
+ JSON.stringify(actualPayload) +
+ " -- Expected (excluding function properties): " +
+ JSON.stringify(expectedPayload)
+ );
+
+ this.Assert.equal(
+ Object.entries(actualPayload).length,
+ Object.entries(expectedPayload).length,
+ "Payload has expected number of properties"
+ );
+
+ for (let [key, expectedValue] of Object.entries(expectedPayload)) {
+ let actualValue = actualPayload[key];
+ if (typeof expectedValue == "function") {
+ this.Assert.ok(expectedValue(actualValue), "Payload property: " + key);
+ } else {
+ this.Assert.equal(
+ actualValue,
+ expectedValue,
+ "Payload property: " + key
+ );
+ }
+ }
+ }
+
+ /**
+ * Asserts that URLs in a result's payload have the timestamp template
+ * substring replaced with real timestamps.
+ *
+ * @param {UrlbarResult} result The results to check
+ * @param {object} urls
+ * An object that contains the expected payload properties with template
+ * substrings. For example:
+ * ```js
+ * {
+ * url: "http://example.com/foo-%YYYYMMDDHH%",
+ * sponsoredClickUrl: "http://example.com/bar-%YYYYMMDDHH%",
+ * }
+ * ```
+ */
+ assertTimestampsReplaced(result, urls) {
+ let { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = lazy.QuickSuggest;
+
+ // Parse the timestamp strings from each payload property and save them in
+ // `urls[key].timestamp`.
+ urls = { ...urls };
+ for (let [key, url] of Object.entries(urls)) {
+ let index = url.indexOf(TIMESTAMP_TEMPLATE);
+ this.Assert.ok(
+ index >= 0,
+ `Timestamp template ${TIMESTAMP_TEMPLATE} is in URL ${url} for key ${key}`
+ );
+ let value = result.payload[key];
+ this.Assert.ok(value, "Key is in result payload: " + key);
+ let timestamp = value.substring(index, index + TIMESTAMP_LENGTH);
+
+ // Set `urls[key]` to an object that's helpful in the logged info message
+ // below.
+ urls[key] = { url, value, timestamp };
+ }
+
+ this.info?.("Parsed timestamps: " + JSON.stringify(urls));
+
+ // Make a set of unique timestamp strings. There should only be one.
+ let { timestamp } = Object.values(urls)[0];
+ this.Assert.deepEqual(
+ [...new Set(Object.values(urls).map(o => o.timestamp))],
+ [timestamp],
+ "There's only one unique timestamp string"
+ );
+
+ // Parse the parts of the timestamp string.
+ let year = timestamp.slice(0, -6);
+ let month = timestamp.slice(-6, -4);
+ let day = timestamp.slice(-4, -2);
+ let hour = timestamp.slice(-2);
+ let date = new Date(year, month - 1, day, hour);
+
+ // The timestamp should be no more than two hours in the past. Typically it
+ // will be the same as the current hour, but since its resolution is in
+ // terms of hours and it's possible the test may have crossed over into a
+ // new hour as it was running, allow for the previous hour.
+ this.Assert.less(
+ Date.now() - 2 * 60 * 60 * 1000,
+ date.getTime(),
+ "Timestamp is within the past two hours"
+ );
+ }
+
+ /**
+ * Calls a callback while enrolled in a mock Nimbus experiment. The experiment
+ * is automatically unenrolled and cleaned up after the callback returns.
+ *
+ * @param {object} options
+ * Options for the mock experiment.
+ * @param {Function} options.callback
+ * The callback to call while enrolled in the mock experiment.
+ * @param {object} options.options
+ * See {@link enrollExperiment}.
+ */
+ async withExperiment({ callback, ...options }) {
+ let doExperimentCleanup = await this.enrollExperiment(options);
+ await callback();
+ await doExperimentCleanup();
+ }
+
+ /**
+ * Enrolls in a mock Nimbus experiment.
+ *
+ * @param {object} options
+ * Options for the mock experiment.
+ * @param {object} [options.valueOverrides]
+ * Values for feature variables.
+ * @returns {Promise<Function>}
+ * The experiment cleanup function (async).
+ */
+ async enrollExperiment({ valueOverrides = {} }) {
+ this.info?.("Awaiting ExperimentAPI.ready");
+ await lazy.ExperimentAPI.ready();
+
+ // Wait for any prior scenario updates to finish. If updates are ongoing,
+ // UrlbarPrefs will ignore the Nimbus update when the experiment is
+ // installed. This shouldn't be a problem in practice because in reality
+ // scenario updates are triggered only on app startup and Nimbus
+ // enrollments, but tests can trigger lots of updates back to back.
+ await this.waitForScenarioUpdated();
+
+ let doExperimentCleanup =
+ await lazy.ExperimentFakes.enrollWithFeatureConfig({
+ enabled: true,
+ featureId: "urlbar",
+ value: valueOverrides,
+ });
+
+ // Wait for the pref updates triggered by the experiment enrollment.
+ this.info?.("Awaiting update after enrolling in experiment");
+ await this.waitForScenarioUpdated();
+
+ return async () => {
+ this.info?.("Awaiting experiment cleanup");
+ await doExperimentCleanup();
+
+ // The same pref updates will be triggered by unenrollment, so wait for
+ // them again.
+ this.info?.("Awaiting update after unenrolling in experiment");
+ await this.waitForScenarioUpdated();
+ };
+ }
+
+ /**
+ * Clears the Nimbus exposure event.
+ */
+ async clearExposureEvent() {
+ // Exposure event recording is queued to the idle thread, so wait for idle
+ // before we start so any events from previous tasks will have been recorded
+ // and won't interfere with this task.
+ await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
+
+ Services.telemetry.clearEvents();
+ lazy.NimbusFeatures.urlbar._didSendExposureEvent = false;
+ lazy.QuickSuggest._recordedExposureEvent = false;
+ }
+
+ /**
+ * Asserts the Nimbus exposure event is recorded or not as expected.
+ *
+ * @param {boolean} expectedRecorded
+ * Whether the event is expected to be recorded.
+ */
+ async assertExposureEvent(expectedRecorded) {
+ this.Assert.equal(
+ lazy.QuickSuggest._recordedExposureEvent,
+ expectedRecorded,
+ "_recordedExposureEvent is correct"
+ );
+
+ let filter = {
+ category: "normandy",
+ method: "expose",
+ object: "nimbus_experiment",
+ };
+
+ let expectedEvents = [];
+ if (expectedRecorded) {
+ expectedEvents.push({
+ ...filter,
+ extra: {
+ branchSlug: "control",
+ featureId: "urlbar",
+ },
+ });
+ }
+
+ // The event recording is queued to the idle thread when the search starts,
+ // so likewise queue the assert to idle instead of doing it immediately.
+ await new Promise(resolve => {
+ Services.tm.idleDispatchToMainThread(() => {
+ lazy.TelemetryTestUtils.assertEvents(expectedEvents, filter);
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Sets the app's locales, calls your callback, and resets locales.
+ *
+ * @param {Array} locales
+ * An array of locale strings. The entire array will be set as the available
+ * locales, and the first locale in the array will be set as the requested
+ * locale.
+ * @param {Function} callback
+ * The callback to be called with the {@link locales} set. This function can
+ * be async.
+ */
+ async withLocales(locales, callback) {
+ let promiseChanges = async desiredLocales => {
+ this.info?.(
+ "Changing locales from " +
+ JSON.stringify(Services.locale.requestedLocales) +
+ " to " +
+ JSON.stringify(desiredLocales)
+ );
+
+ if (desiredLocales[0] == Services.locale.requestedLocales[0]) {
+ // Nothing happens when the locale doesn't actually change.
+ return;
+ }
+
+ this.info?.("Waiting for intl:requested-locales-changed");
+ await lazy.TestUtils.topicObserved("intl:requested-locales-changed");
+ this.info?.("Observed intl:requested-locales-changed");
+
+ // Wait for the search service to reload engines. Otherwise tests can fail
+ // in strange ways due to internal search service state during shutdown.
+ // It won't always reload engines but it's hard to tell in advance when it
+ // won't, so also set a timeout.
+ this.info?.("Waiting for TOPIC_SEARCH_SERVICE");
+ await Promise.race([
+ lazy.TestUtils.topicObserved(
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ (subject, data) => {
+ this.info?.("Observed TOPIC_SEARCH_SERVICE with data: " + data);
+ return data == "engines-reloaded";
+ }
+ ),
+ new Promise(resolve => {
+ lazy.setTimeout(() => {
+ this.info?.("Timed out waiting for TOPIC_SEARCH_SERVICE");
+ resolve();
+ }, 2000);
+ }),
+ ]);
+
+ this.info?.("Done waiting for locale changes");
+ };
+
+ let available = Services.locale.availableLocales;
+ let requested = Services.locale.requestedLocales;
+
+ let newRequested = locales.slice(0, 1);
+ let promise = promiseChanges(newRequested);
+ Services.locale.availableLocales = locales;
+ Services.locale.requestedLocales = newRequested;
+ await promise;
+
+ this.Assert.equal(
+ Services.locale.appLocaleAsBCP47,
+ locales[0],
+ "App locale is now " + locales[0]
+ );
+
+ await callback();
+
+ promise = promiseChanges(requested);
+ Services.locale.availableLocales = available;
+ Services.locale.requestedLocales = requested;
+ await promise;
+ }
+
+ #mockRemoteSettings = null;
+}
+
+export var QuickSuggestTestUtils = new _QuickSuggestTestUtils();
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser.ini b/browser/components/urlbar/tests/quicksuggest/browser/browser.ini
new file mode 100644
index 0000000000..a29ef67770
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser.ini
@@ -0,0 +1,37 @@
+# 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/.
+
+[DEFAULT]
+support-files =
+ head.js
+ searchSuggestionEngine.xml
+ searchSuggestionEngine.sjs
+ subdialog.xhtml
+
+[browser_quicksuggest.js]
+[browser_quicksuggest_addons.js]
+[browser_quicksuggest_block.js]
+[browser_quicksuggest_configuration.js]
+[browser_quicksuggest_indexes.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_quicksuggest_merinoSessions.js]
+[browser_quicksuggest_onboardingDialog.js]
+skip-if =
+ os == 'linux' && bits == 64 # Bug 1773830
+[browser_telemetry_dynamicWikipedia.js]
+tags = search-telemetry
+[browser_telemetry_impressionEdgeCases.js]
+tags = search-telemetry
+[browser_telemetry_navigationalSuggestions.js]
+tags = search-telemetry
+[browser_telemetry_nonsponsored.js]
+tags = search-telemetry
+[browser_telemetry_other.js]
+tags = search-telemetry
+[browser_telemetry_sponsored.js]
+tags = search-telemetry
+[browser_telemetry_weather.js]
+tags = search-telemetry
+[browser_weather.js]
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js
new file mode 100644
index 0000000000..d11f6d6386
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests browser quick suggestions.
+ */
+
+const TEST_URL = "http://example.com/quicksuggest";
+
+const REMOTE_SETTINGS_RESULTS = [
+ {
+ id: 1,
+ url: `${TEST_URL}?q=frabbits`,
+ title: "frabbits",
+ keywords: ["fra", "frab"],
+ click_url: "http://click.reporting.test.com/",
+ impression_url: "http://impression.reporting.test.com/",
+ advertiser: "TestAdvertiser",
+ },
+ {
+ id: 2,
+ url: `${TEST_URL}?q=nonsponsored`,
+ title: "Non-Sponsored",
+ keywords: ["nonspon"],
+ click_url: "http://click.reporting.test.com/nonsponsored",
+ impression_url: "http://impression.reporting.test.com/nonsponsored",
+ advertiser: "TestAdvertiserNonSponsored",
+ iab_category: "5 - Education",
+ },
+];
+
+add_setup(async function () {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsResults: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ });
+});
+
+// Tests a sponsored result and keyword highlighting.
+add_task(async function sponsored() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "fra",
+ });
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ index: 1,
+ isSponsored: true,
+ url: `${TEST_URL}?q=frabbits`,
+ });
+ let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
+ Assert.equal(
+ row.querySelector(".urlbarView-title").firstChild.textContent,
+ "fra",
+ "The part of the keyword that matches users input is not bold."
+ );
+ Assert.equal(
+ row.querySelector(".urlbarView-title > strong").textContent,
+ "b",
+ "The auto completed section of the keyword is bolded."
+ );
+ await UrlbarTestUtils.promisePopupClose(window);
+});
+
+// Tests a non-sponsored result.
+add_task(async function nonSponsored() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "nonspon",
+ });
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ index: 1,
+ isSponsored: false,
+ url: `${TEST_URL}?q=nonsponsored`,
+ });
+ await UrlbarTestUtils.promisePopupClose(window);
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js
new file mode 100644
index 0000000000..e0b75d0e9b
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js
@@ -0,0 +1,560 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for addon suggestions.
+
+const TEST_MERINO_SUGGESTIONS = [
+ {
+ provider: "amo",
+ icon: "https://example.com/first.svg",
+ url: "https://example.com/first-addon",
+ title: "First Addon",
+ description: "This is a first addon",
+ custom_details: {
+ amo: {
+ rating: "5",
+ number_of_ratings: "1234567",
+ guid: "first@addon",
+ },
+ },
+ is_top_pick: true,
+ },
+ {
+ provider: "amo",
+ icon: "https://example.com/second.png",
+ url: "https://example.com/second-addon",
+ title: "Second Addon",
+ description: "This is a second addon",
+ custom_details: {
+ amo: {
+ rating: "4.5",
+ number_of_ratings: "123",
+ guid: "second@addon",
+ },
+ },
+ is_sponsored: true,
+ is_top_pick: false,
+ },
+ {
+ provider: "amo",
+ icon: "https://example.com/third.svg",
+ url: "https://example.com/third-addon",
+ title: "Third Addon",
+ description: "This is a third addon",
+ custom_details: {
+ amo: {
+ rating: "0",
+ number_of_ratings: "0",
+ guid: "third@addon",
+ },
+ },
+ is_top_pick: false,
+ },
+ {
+ provider: "amo",
+ icon: "https://example.com/fourth.svg",
+ url: "https://example.com/fourth-addon",
+ title: "Fourth Addon",
+ description: "This is a fourth addon",
+ custom_details: {
+ amo: {
+ rating: "4",
+ number_of_ratings: "4",
+ guid: "fourth@addon",
+ },
+ },
+ },
+];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.quicksuggest.enabled", true],
+ ["browser.urlbar.quicksuggest.remoteSettings.enabled", false],
+ ["browser.urlbar.bestMatch.enabled", true],
+ ],
+ });
+
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ merinoSuggestions: TEST_MERINO_SUGGESTIONS,
+ });
+});
+
+add_task(async function basic() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.addons.featureGate", true]],
+ });
+
+ for (const merinoSuggestion of TEST_MERINO_SUGGESTIONS) {
+ MerinoTestUtils.server.response.body.suggestions = [merinoSuggestion];
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "only match the Merino suggestion",
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element, result } = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ 1
+ );
+ const row = element.row;
+ const icon = row.querySelector(".urlbarView-dynamic-addons-icon");
+ Assert.equal(icon.src, merinoSuggestion.icon);
+ const url = row.querySelector(".urlbarView-dynamic-addons-url");
+ Assert.equal(url.textContent, merinoSuggestion.url);
+ const title = row.querySelector(".urlbarView-dynamic-addons-title");
+ Assert.equal(title.textContent, merinoSuggestion.title);
+ const description = row.querySelector(
+ ".urlbarView-dynamic-addons-description"
+ );
+ Assert.equal(description.textContent, merinoSuggestion.description);
+ const reviews = row.querySelector(".urlbarView-dynamic-addons-reviews");
+ Assert.equal(
+ reviews.textContent,
+ `${new Intl.NumberFormat().format(
+ Number(merinoSuggestion.custom_details.amo.number_of_ratings)
+ )} reviews`
+ );
+
+ const isTopPick = merinoSuggestion.is_top_pick ?? true;
+ if (isTopPick) {
+ Assert.equal(result.suggestedIndex, 1);
+ } else if (merinoSuggestion.is_sponsored) {
+ Assert.equal(
+ result.suggestedIndex,
+ UrlbarPrefs.get("quickSuggestSponsoredIndex")
+ );
+ } else {
+ Assert.equal(
+ result.suggestedIndex,
+ UrlbarPrefs.get("quickSuggestNonSponsoredIndex")
+ );
+ }
+
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ merinoSuggestion.url
+ );
+ EventUtils.synthesizeMouseAtCenter(row, {});
+ await onLoad;
+ Assert.ok(true, "Expected page is loaded");
+
+ await PlacesUtils.history.clear();
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function ratings() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.addons.featureGate", true]],
+ });
+
+ const testRating = [
+ "0",
+ "0.24",
+ "0.25",
+ "0.74",
+ "0.75",
+ "1",
+ "1.24",
+ "1.25",
+ "1.74",
+ "1.75",
+ "2",
+ "2.24",
+ "2.25",
+ "2.74",
+ "2.75",
+ "3",
+ "3.24",
+ "3.25",
+ "3.74",
+ "3.75",
+ "4",
+ "4.24",
+ "4.25",
+ "4.74",
+ "4.75",
+ "5",
+ ];
+ const baseMerinoSuggestion = JSON.parse(
+ JSON.stringify(TEST_MERINO_SUGGESTIONS[0])
+ );
+
+ for (const rating of testRating) {
+ baseMerinoSuggestion.custom_details.amo.rating = rating;
+ MerinoTestUtils.server.response.body.suggestions = [baseMerinoSuggestion];
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "only match the Merino suggestion",
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+
+ const ratingElements = element.row.querySelectorAll(
+ ".urlbarView-dynamic-addons-rating"
+ );
+ Assert.equal(ratingElements.length, 5);
+
+ for (let i = 0; i < ratingElements.length; i++) {
+ const ratingElement = ratingElements[i];
+
+ const distanceToFull = Number(rating) - i;
+ let fill = "full";
+ if (distanceToFull < 0.25) {
+ fill = "empty";
+ } else if (distanceToFull < 0.75) {
+ fill = "half";
+ }
+ Assert.equal(ratingElement.getAttribute("fill"), fill);
+ }
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function disable() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.addons.featureGate", false]],
+ });
+
+ // Restore AdmWikipedia suggestions.
+ MerinoTestUtils.server.reset();
+ // Add one Addon suggestion that is higher score than AdmWikipedia.
+ MerinoTestUtils.server.response.body.suggestions.push(
+ Object.assign({}, TEST_MERINO_SUGGESTIONS[0], { score: 2 })
+ );
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "only match the Merino suggestion",
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ Assert.equal(result.payload.telemetryType, "adm_sponsored");
+
+ MerinoTestUtils.server.response.body.suggestions = TEST_MERINO_SUGGESTIONS;
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function resultMenu_showLessFrequently() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.addons.featureGate", true],
+ ["browser.urlbar.addons.showLessFrequentlyCount", 0],
+ ],
+ });
+
+ const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({
+ addonsShowLessFrequentlyCap: 3,
+ });
+
+ // Sanity check.
+ Assert.equal(UrlbarPrefs.get("addonsShowLessFrequentlyCap"), 3);
+ Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 0);
+
+ await doShowLessFrequently({
+ input: "aaa b",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: true,
+ },
+ });
+ Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 1);
+
+ await doShowLessFrequently({
+ input: "aaa b",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: true,
+ },
+ });
+ Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 2);
+
+ await doShowLessFrequently({
+ input: "aaa b",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: true,
+ },
+ });
+ Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 3);
+
+ await doShowLessFrequently({
+ input: "aaa b",
+ expected: {
+ // The suggestion should not display since addons.showLessFrequentlyCount
+ // is 3 and the substring (" b") after the first word ("aaa") is 2 chars
+ // long.
+ isSuggestionShown: false,
+ },
+ });
+
+ await doShowLessFrequently({
+ input: "aaa bb",
+ expected: {
+ // The suggestion should display, but item should not shown since the
+ // addons.showLessFrequentlyCount reached to addonsShowLessFrequentlyCap
+ // already.
+ isSuggestionShown: true,
+ isMenuItemShown: false,
+ },
+ });
+
+ await cleanUpNimbus();
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests the "Not interested" result menu dismissal command.
+add_task(async function resultMenu_notInterested() {
+ await doDismissTest("not_interested");
+});
+
+// Tests the "Not relevant" result menu dismissal command.
+add_task(async function notRelevant() {
+ await doDismissTest("not_relevant");
+});
+
+add_task(async function rowLabel() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.addons.featureGate", true]],
+ });
+
+ const testCases = [
+ {
+ bestMatch: true,
+ expected: "Firefox extension",
+ },
+ {
+ bestMatch: false,
+ expected: "Firefox Suggest",
+ },
+ ];
+
+ for (const { bestMatch, expected } of testCases) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.bestMatch.enabled", bestMatch]],
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "only match the Merino suggestion",
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ const row = element.row;
+ Assert.equal(row.getAttribute("label"), expected);
+
+ await SpecialPowers.popPrefEnv();
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function treatmentB() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.addons.featureGate", true]],
+ });
+
+ const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({
+ addonsUITreatment: "b",
+ });
+ // Sanity check.
+ Assert.equal(UrlbarPrefs.get("addonsUITreatment"), "b");
+
+ const merinoSuggestion = TEST_MERINO_SUGGESTIONS[0];
+ MerinoTestUtils.server.response.body.suggestions = [merinoSuggestion];
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "only match the Merino suggestion",
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ const row = element.row;
+ const icon = row.querySelector(".urlbarView-dynamic-addons-icon");
+ Assert.equal(icon.src, merinoSuggestion.icon);
+ const url = row.querySelector(".urlbarView-dynamic-addons-url");
+ Assert.equal(url.textContent, merinoSuggestion.url);
+ const title = row.querySelector(".urlbarView-dynamic-addons-title");
+ Assert.equal(title.textContent, merinoSuggestion.title);
+ const description = row.querySelector(
+ ".urlbarView-dynamic-addons-description"
+ );
+ Assert.equal(description.textContent, merinoSuggestion.description);
+ const ratingContainer = row.querySelector(
+ ".urlbarView-dynamic-addons-ratingContainer"
+ );
+ Assert.ok(BrowserTestUtils.is_hidden(ratingContainer));
+ const reviews = row.querySelector(".urlbarView-dynamic-addons-reviews");
+ Assert.equal(reviews.textContent, "Recommended");
+
+ await cleanUpNimbus();
+ await SpecialPowers.popPrefEnv();
+});
+
+async function doShowLessFrequently({ input, expected }) {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: input,
+ });
+
+ if (!expected.isSuggestionShown) {
+ for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
+ const details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.notEqual(
+ details.result.payload.dynamicType,
+ "addons",
+ `Addons suggestion should be absent (checking index ${i})`
+ );
+ }
+
+ return;
+ }
+
+ const resultIndex = 1;
+ const details = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ resultIndex
+ );
+ Assert.equal(
+ details.result.payload.dynamicType,
+ "addons",
+ `Addons suggestion should be present at expected index after ${input} search`
+ );
+
+ // Click the command.
+ try {
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ "show_less_frequently",
+ {
+ resultIndex,
+ }
+ );
+ Assert.ok(expected.isMenuItemShown);
+ Assert.ok(
+ gURLBar.view.isOpen,
+ "The view should remain open clicking the command"
+ );
+ Assert.ok(
+ details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should have feedback acknowledgment after clicking command"
+ );
+ } catch (e) {
+ Assert.ok(!expected.isMenuItemShown);
+ Assert.ok(
+ !details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should not have feedback acknowledgment after clicking command"
+ );
+ Assert.equal(
+ e.message,
+ "Menu item not found for command: show_less_frequently"
+ );
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+}
+
+async function doDismissTest(command) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.addons.featureGate", true]],
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "123",
+ });
+
+ const resultCount = UrlbarTestUtils.getResultCount(window);
+ const resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.result.payload.dynamicType,
+ "addons",
+ "Addons suggestion should be present"
+ );
+
+ // Sanity check.
+ Assert.ok(UrlbarPrefs.get("suggest.addons"));
+
+ // Click the command.
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command],
+ { resultIndex, openByMouse: true }
+ );
+
+ Assert.ok(
+ !UrlbarPrefs.get("suggest.addons"),
+ "suggest.addons pref should be set to false after dismissal"
+ );
+
+ // The row should be a tip now.
+ Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal");
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ resultCount,
+ "The result count should not haved changed after dismissal"
+ );
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.type,
+ UrlbarUtils.RESULT_TYPE.TIP,
+ "Row should be a tip after dismissal"
+ );
+ Assert.equal(
+ details.result.payload.type,
+ "dismissalAcknowledgment",
+ "Tip type should be dismissalAcknowledgment"
+ );
+ Assert.ok(
+ !details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should not have feedback acknowledgment after dismissal"
+ );
+
+ // Get the dismissal acknowledgment's "Got it" button and click it.
+ let gotItButton = UrlbarTestUtils.getButtonForResultIndex(
+ window,
+ "0",
+ resultIndex
+ );
+ Assert.ok(gotItButton, "Row should have a 'Got it' button");
+ EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window);
+
+ // The view should remain open and the tip row should be gone.
+ Assert.ok(
+ gURLBar.view.isOpen,
+ "The view should remain open clicking the 'Got it' button"
+ );
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ resultCount - 1,
+ "The result count should be one less after clicking 'Got it' button"
+ );
+ for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.ok(
+ details.type != UrlbarUtils.RESULT_TYPE.TIP &&
+ details.result.payload.dynamicType !== "addons",
+ "Tip result and addon result should not be present"
+ );
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ await SpecialPowers.popPrefEnv();
+ UrlbarPrefs.clear("suggest.addons");
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js
new file mode 100644
index 0000000000..081818c02b
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js
@@ -0,0 +1,445 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests blocking quick suggest results, including best matches. See also:
+//
+// browser_bestMatch.js
+// Includes tests for blocking best match rows independent of quick suggest,
+// especially the superficial UI part that should be common to all types of
+// best matches
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+});
+
+const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;
+const { TIMESTAMP_TEMPLATE } = QuickSuggest;
+
+// Include the timestamp template in the suggestion URLs so we can make sure
+// their original URLs with the unreplaced templates are blocked and not their
+// URLs with timestamps.
+const REMOTE_SETTINGS_RESULTS = [
+ {
+ id: 1,
+ url: `https://example.com/sponsored?t=${TIMESTAMP_TEMPLATE}`,
+ title: "Sponsored suggestion",
+ keywords: ["sponsored"],
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "TestAdvertiser",
+ iab_category: "22 - Shopping",
+ },
+ {
+ id: 2,
+ url: `https://example.com/nonsponsored?t=${TIMESTAMP_TEMPLATE}`,
+ title: "Non-sponsored suggestion",
+ keywords: ["nonsponsored"],
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "TestAdvertiser",
+ iab_category: "5 - Education",
+ },
+];
+
+// Spy for the custom impression/click sender
+let spy;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.bestMatch.blockingEnabled", true],
+ ["browser.urlbar.quicksuggest.blockingEnabled", true],
+ ],
+ });
+
+ ({ spy } = QuickSuggestTestUtils.createTelemetryPingSpy());
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ await QuickSuggest.blockedSuggestions._test_readyPromise;
+ await QuickSuggest.blockedSuggestions.clear();
+
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsResults: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ config: QuickSuggestTestUtils.BEST_MATCH_CONFIG,
+ });
+});
+
+/**
+ * Adds a test task that runs the given callback with combinations of the
+ * following:
+ *
+ * - Best match disabled and enabled
+ * - Each result in `REMOTE_SETTINGS_RESULTS`
+ *
+ * @param {Function} fn
+ * The callback function. It's passed: `{ isBestMatch, suggestion }`
+ */
+function add_combo_task(fn) {
+ let taskFn = async () => {
+ for (let isBestMatch of [false, true]) {
+ UrlbarPrefs.set("bestMatch.enabled", isBestMatch);
+ for (let result of REMOTE_SETTINGS_RESULTS) {
+ info(`Running ${fn.name}: ${JSON.stringify({ isBestMatch, result })}`);
+ await fn({ isBestMatch, result });
+ }
+ UrlbarPrefs.clear("bestMatch.enabled");
+ }
+ };
+ Object.defineProperty(taskFn, "name", { value: fn.name });
+ add_task(taskFn);
+}
+
+// Picks the block button with the keyboard.
+add_combo_task(async function basic_keyboard({ result, isBestMatch }) {
+ await doBasicBlockTest({
+ result,
+ isBestMatch,
+ block: async () => {
+ if (UrlbarPrefs.get("resultMenu")) {
+ await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", {
+ resultIndex: 1,
+ });
+ } else {
+ // TAB twice to select the block button: once to select the main
+ // part of the row, once to select the block button.
+ EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 });
+ EventUtils.synthesizeKey("KEY_Enter");
+ }
+ },
+ });
+});
+
+// Picks the block button with the mouse.
+add_combo_task(async function basic_mouse({ result, isBestMatch }) {
+ await doBasicBlockTest({
+ result,
+ isBestMatch,
+ block: async () => {
+ if (UrlbarPrefs.get("resultMenu")) {
+ await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", {
+ resultIndex: 1,
+ openByMouse: true,
+ });
+ } else {
+ EventUtils.synthesizeMouseAtCenter(
+ UrlbarTestUtils.getButtonForResultIndex(window, "block", 1),
+ {}
+ );
+ }
+ },
+ });
+});
+
+// Uses the key shortcut to block a suggestion.
+add_combo_task(async function basic_keyShortcut({ result, isBestMatch }) {
+ await doBasicBlockTest({
+ result,
+ isBestMatch,
+ block: () => {
+ // Arrow down once to select the row.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true });
+ },
+ });
+});
+
+async function doBasicBlockTest({ result, isBestMatch, block }) {
+ spy.resetHistory();
+
+ // Do a search that triggers the suggestion.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: result.keywords[0],
+ });
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ 2,
+ "Two rows are present after searching (heuristic + suggestion)"
+ );
+
+ let isSponsored = result.keywords[0] == "sponsored";
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ isBestMatch,
+ isSponsored,
+ originalUrl: result.url,
+ });
+
+ // Block the suggestion.
+ await block();
+
+ // The row should have been removed.
+ Assert.ok(
+ UrlbarTestUtils.isPopupOpen(window),
+ "View remains open after blocking result"
+ );
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ 1,
+ "Only one row after blocking suggestion"
+ );
+ await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
+
+ // The URL should be blocked.
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(result.url),
+ "Suggestion is blocked"
+ );
+
+ // Check telemetry scalars.
+ let index = 2;
+ let scalars = {};
+ if (isSponsored) {
+ scalars[TELEMETRY_SCALARS.IMPRESSION_SPONSORED] = index;
+ scalars[TELEMETRY_SCALARS.BLOCK_SPONSORED] = index;
+ } else {
+ scalars[TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED] = index;
+ scalars[TELEMETRY_SCALARS.BLOCK_NONSPONSORED] = index;
+ }
+ if (isBestMatch) {
+ if (isSponsored) {
+ scalars = {
+ ...scalars,
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: index,
+ [TELEMETRY_SCALARS.BLOCK_SPONSORED_BEST_MATCH]: index,
+ };
+ } else {
+ scalars = {
+ ...scalars,
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: index,
+ [TELEMETRY_SCALARS.BLOCK_NONSPONSORED_BEST_MATCH]: index,
+ };
+ }
+ }
+ QuickSuggestTestUtils.assertScalars(scalars);
+
+ // Check the engagement event.
+ let match_type = isBestMatch ? "best-match" : "firefox-suggest";
+ QuickSuggestTestUtils.assertEvents([
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ match_type,
+ position: String(index),
+ suggestion_type: isSponsored ? "sponsored" : "nonsponsored",
+ },
+ },
+ ]);
+
+ // Check the custom telemetry pings.
+ QuickSuggestTestUtils.assertPings(spy, [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ block_id: result.id,
+ is_clicked: false,
+ position: index,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ payload: {
+ match_type,
+ block_id: result.id,
+ iab_category: result.iab_category,
+ position: index,
+ },
+ },
+ ]);
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await QuickSuggest.blockedSuggestions.clear();
+}
+
+// Blocks multiple suggestions one after the other.
+add_task(async function blockMultiple() {
+ for (let isBestMatch of [false, true]) {
+ UrlbarPrefs.set("bestMatch.enabled", isBestMatch);
+ info(`Testing with best match enabled: ${isBestMatch}`);
+
+ for (let i = 0; i < REMOTE_SETTINGS_RESULTS.length; i++) {
+ // Do a search that triggers the i'th suggestion.
+ let { keywords, url } = REMOTE_SETTINGS_RESULTS[i];
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: keywords[0],
+ });
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ isBestMatch,
+ originalUrl: url,
+ isSponsored: keywords[0] == "sponsored",
+ });
+
+ // Block it.
+ if (UrlbarPrefs.get("resultMenu")) {
+ await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", {
+ resultIndex: 1,
+ });
+ } else {
+ EventUtils.synthesizeKey("KEY_Tab", { repeat: 2 });
+ EventUtils.synthesizeKey("KEY_Enter");
+ }
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(url),
+ "Suggestion is blocked after picking block button"
+ );
+
+ // Make sure all previous suggestions remain blocked and no other
+ // suggestions are blocked yet.
+ for (let j = 0; j < REMOTE_SETTINGS_RESULTS.length; j++) {
+ Assert.equal(
+ await QuickSuggest.blockedSuggestions.has(
+ REMOTE_SETTINGS_RESULTS[j].url
+ ),
+ j <= i,
+ `Suggestion at index ${j} is blocked or not as expected`
+ );
+ }
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await QuickSuggest.blockedSuggestions.clear();
+ UrlbarPrefs.clear("bestMatch.enabled");
+ }
+});
+
+// Tests with blocking disabled for both best matches and non-best-matches.
+add_combo_task(async function disabled_both({ result, isBestMatch }) {
+ await doDisabledTest({
+ result,
+ isBestMatch,
+ quickSuggestBlockingEnabled: false,
+ bestMatchBlockingEnabled: false,
+ });
+});
+
+// Tests with blocking disabled only for non-best-matches.
+add_combo_task(async function disabled_quickSuggest({ result, isBestMatch }) {
+ await doDisabledTest({
+ result,
+ isBestMatch,
+ quickSuggestBlockingEnabled: false,
+ bestMatchBlockingEnabled: true,
+ });
+});
+
+// Tests with blocking disabled only for best matches.
+add_combo_task(async function disabled_bestMatch({ result, isBestMatch }) {
+ await doDisabledTest({
+ result,
+ isBestMatch,
+ quickSuggestBlockingEnabled: true,
+ bestMatchBlockingEnabled: false,
+ });
+});
+
+async function doDisabledTest({
+ result,
+ isBestMatch,
+ bestMatchBlockingEnabled,
+ quickSuggestBlockingEnabled,
+}) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.bestMatch.blockingEnabled", bestMatchBlockingEnabled],
+ [
+ "browser.urlbar.quicksuggest.blockingEnabled",
+ quickSuggestBlockingEnabled,
+ ],
+ ],
+ });
+
+ // Do a search to show a suggestion.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: result.keywords[0],
+ });
+ let expectedResultCount = 2;
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ expectedResultCount,
+ "Two rows are present after searching (heuristic + suggestion)"
+ );
+ let details = await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ isBestMatch,
+ originalUrl: result.url,
+ isSponsored: result.keywords[0] == "sponsored",
+ });
+
+ // Arrow down to select the suggestion and press the key shortcut to block.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true });
+ Assert.ok(
+ UrlbarTestUtils.isPopupOpen(window),
+ "View remains open after trying to block result"
+ );
+
+ if (
+ (isBestMatch && !bestMatchBlockingEnabled) ||
+ (!isBestMatch && !quickSuggestBlockingEnabled)
+ ) {
+ // Blocking is disabled. The key shortcut shouldn't have done anything.
+ if (!UrlbarPrefs.get("resultMenu")) {
+ Assert.ok(
+ !details.element.row._buttons.get("block"),
+ "Block button is not present"
+ );
+ }
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ expectedResultCount,
+ "Same number of results after key shortcut"
+ );
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ isBestMatch,
+ originalUrl: result.url,
+ isSponsored: result.keywords[0] == "sponsored",
+ });
+ Assert.ok(
+ !(await QuickSuggest.blockedSuggestions.has(result.url)),
+ "Suggestion is not blocked"
+ );
+ } else {
+ // Blocking is enabled. The suggestion should have been blocked.
+ if (!UrlbarPrefs.get("resultMenu")) {
+ Assert.ok(
+ details.element.row._buttons.get("block"),
+ "Block button is present"
+ );
+ }
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ 1,
+ "Only one row after blocking suggestion"
+ );
+ await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(result.url),
+ "Suggestion is blocked"
+ );
+ await QuickSuggest.blockedSuggestions.clear();
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await SpecialPowers.popPrefEnv();
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js
new file mode 100644
index 0000000000..066ffecd51
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js
@@ -0,0 +1,2101 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests QuickSuggest configurations.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ EnterprisePolicyTesting:
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+// We use this pref in enterprise preference policy tests. We specifically use a
+// pref that's sticky and exposed in the UI to make sure it can be set properly.
+const POLICY_PREF = "suggest.quicksuggest.nonsponsored";
+
+let gDefaultBranch = Services.prefs.getDefaultBranch("browser.urlbar.");
+let gUserBranch = Services.prefs.getBranch("browser.urlbar.");
+
+add_setup(async function () {
+ await QuickSuggestTestUtils.ensureQuickSuggestInit();
+});
+
+// Makes sure `QuickSuggest._updateFeatureState()` is called when the
+// `browser.urlbar.quicksuggest.enabled` pref is changed.
+add_task(async function test_updateFeatureState_pref() {
+ Assert.ok(
+ UrlbarPrefs.get("quicksuggest.enabled"),
+ "Sanity check: quicksuggest.enabled is true by default"
+ );
+
+ let sandbox = sinon.createSandbox();
+ let spy = sandbox.spy(QuickSuggest, "_updateFeatureState");
+
+ UrlbarPrefs.set("quicksuggest.enabled", false);
+ Assert.equal(
+ spy.callCount,
+ 1,
+ "_updateFeatureState called once after changing pref"
+ );
+
+ UrlbarPrefs.clear("quicksuggest.enabled");
+ Assert.equal(
+ spy.callCount,
+ 2,
+ "_updateFeatureState called again after clearing pref"
+ );
+
+ sandbox.restore();
+});
+
+// Makes sure `QuickSuggest._updateFeatureState()` is called when a Nimbus
+// experiment is installed and uninstalled.
+add_task(async function test_updateFeatureState_experiment() {
+ let sandbox = sinon.createSandbox();
+ let spy = sandbox.spy(QuickSuggest, "_updateFeatureState");
+
+ await QuickSuggestTestUtils.withExperiment({
+ callback: () => {
+ Assert.equal(
+ spy.callCount,
+ 1,
+ "_updateFeatureState called once after installing experiment"
+ );
+ },
+ });
+
+ Assert.equal(
+ spy.callCount,
+ 2,
+ "_updateFeatureState called again after uninstalling experiment"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_indexes() {
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ quickSuggestNonSponsoredIndex: 99,
+ quickSuggestSponsoredIndex: -1337,
+ },
+ callback: () => {
+ Assert.equal(
+ UrlbarPrefs.get("quickSuggestNonSponsoredIndex"),
+ 99,
+ "quickSuggestNonSponsoredIndex"
+ );
+ Assert.equal(
+ UrlbarPrefs.get("quickSuggestSponsoredIndex"),
+ -1337,
+ "quickSuggestSponsoredIndex"
+ );
+ },
+ });
+});
+
+add_task(async function test_merino() {
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ merinoEnabled: true,
+ merinoEndpointURL: "http://example.com/test_merino_config",
+ merinoClientVariants: "test-client-variants",
+ merinoProviders: "test-providers",
+ },
+ callback: () => {
+ Assert.equal(UrlbarPrefs.get("merinoEnabled"), true, "merinoEnabled");
+ Assert.equal(
+ UrlbarPrefs.get("merinoEndpointURL"),
+ "http://example.com/test_merino_config",
+ "merinoEndpointURL"
+ );
+ Assert.equal(
+ UrlbarPrefs.get("merinoClientVariants"),
+ "test-client-variants",
+ "merinoClientVariants"
+ );
+ Assert.equal(
+ UrlbarPrefs.get("merinoProviders"),
+ "test-providers",
+ "merinoProviders"
+ );
+ },
+ });
+});
+
+add_task(async function test_scenario_online() {
+ await doBasicScenarioTest("online", {
+ urlbarPrefs: {
+ // prefs
+ "quicksuggest.scenario": "online",
+ "quicksuggest.enabled": true,
+ "quicksuggest.dataCollection.enabled": false,
+ "quicksuggest.shouldShowOnboardingDialog": true,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+
+ // Nimbus variables
+ quickSuggestScenario: "online",
+ quickSuggestEnabled: true,
+ quickSuggestShouldShowOnboardingDialog: true,
+ },
+ defaults: [
+ {
+ name: "browser.urlbar.quicksuggest.enabled",
+ value: true,
+ },
+ {
+ name: "browser.urlbar.quicksuggest.dataCollection.enabled",
+ value: false,
+ },
+ {
+ name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog",
+ value: true,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.nonsponsored",
+ value: true,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.sponsored",
+ value: true,
+ },
+ ],
+ });
+});
+
+add_task(async function test_scenario_offline() {
+ await doBasicScenarioTest("offline", {
+ urlbarPrefs: {
+ // prefs
+ "quicksuggest.scenario": "offline",
+ "quicksuggest.enabled": true,
+ "quicksuggest.dataCollection.enabled": false,
+ "quicksuggest.shouldShowOnboardingDialog": false,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+
+ // Nimbus variables
+ quickSuggestScenario: "offline",
+ quickSuggestEnabled: true,
+ quickSuggestShouldShowOnboardingDialog: false,
+ },
+ defaults: [
+ {
+ name: "browser.urlbar.quicksuggest.enabled",
+ value: true,
+ },
+ {
+ name: "browser.urlbar.quicksuggest.dataCollection.enabled",
+ value: false,
+ },
+ {
+ name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog",
+ value: false,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.nonsponsored",
+ value: true,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.sponsored",
+ value: true,
+ },
+ ],
+ });
+});
+
+add_task(async function test_scenario_history() {
+ await doBasicScenarioTest("history", {
+ urlbarPrefs: {
+ // prefs
+ "quicksuggest.scenario": "history",
+ "quicksuggest.enabled": false,
+
+ // Nimbus variables
+ quickSuggestScenario: "history",
+ quickSuggestEnabled: false,
+ },
+ defaults: [
+ {
+ name: "browser.urlbar.quicksuggest.enabled",
+ value: false,
+ },
+ ],
+ });
+});
+
+async function doBasicScenarioTest(scenario, expectedPrefs) {
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ quickSuggestScenario: scenario,
+ },
+ callback: () => {
+ // Pref updates should always settle down by the time enrollment is done.
+ Assert.ok(
+ !UrlbarPrefs.updatingFirefoxSuggestPrefs,
+ "updatingFirefoxSuggestPrefs is false"
+ );
+
+ assertScenarioPrefs(expectedPrefs);
+ },
+ });
+
+ // Similarly, pref updates should always settle down by the time unenrollment
+ // is done.
+ Assert.ok(
+ !UrlbarPrefs.updatingFirefoxSuggestPrefs,
+ "updatingFirefoxSuggestPrefs is false"
+ );
+
+ assertDefaultScenarioPrefs();
+}
+
+function assertScenarioPrefs({ urlbarPrefs, defaults }) {
+ for (let [name, value] of Object.entries(urlbarPrefs)) {
+ Assert.equal(UrlbarPrefs.get(name), value, `UrlbarPrefs.get("${name}")`);
+ }
+
+ let prefs = Services.prefs.getDefaultBranch("");
+ for (let { name, getter, value } of defaults) {
+ Assert.equal(
+ prefs[getter || "getBoolPref"](name),
+ value,
+ `Default branch pref: ${name}`
+ );
+ }
+}
+
+function assertDefaultScenarioPrefs() {
+ assertScenarioPrefs({
+ urlbarPrefs: {
+ "quicksuggest.scenario": "offline",
+ "quicksuggest.enabled": true,
+ "quicksuggest.dataCollection.enabled": false,
+ "quicksuggest.shouldShowOnboardingDialog": false,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+
+ // No Nimbus variables since they're only available when an experiment is
+ // installed.
+ },
+ defaults: [
+ {
+ name: "browser.urlbar.quicksuggest.enabled",
+ value: true,
+ },
+ {
+ name: "browser.urlbar.quicksuggest.dataCollection.enabled",
+ value: false,
+ },
+ {
+ name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog",
+ value: false,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.nonsponsored",
+ value: true,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.sponsored",
+ value: true,
+ },
+ ],
+ });
+}
+
+function clearOnboardingPrefs() {
+ UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored");
+ UrlbarPrefs.clear("suggest.quicksuggest.sponsored");
+ UrlbarPrefs.clear("quicksuggest.dataCollection.enabled");
+ UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.seenRestarts");
+}
+
+// The following tasks test Nimbus enrollments
+
+// Initial state:
+// * History (quick suggest feature disabled)
+//
+// Enrollment:
+// * History
+//
+// Expected:
+// * All history prefs set on the default branch
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "history",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history,
+ },
+ });
+});
+
+// Initial state:
+// * History (quick suggest feature disabled)
+//
+// Enrollment:
+// * Offline
+//
+// Expected:
+// * All offline prefs set on the default branch
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ },
+ });
+});
+
+// Initial state:
+// * History (quick suggest feature disabled)
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * All online prefs set on the default branch
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.history,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// The following tasks test OFFLINE TO OFFLINE
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * User did not override any defaults
+//
+// Enrollment:
+// * Offline
+//
+// Expected:
+// * All offline prefs set on the default branch
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Enrollment:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain off
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user left on
+// * Data collection: user left off
+//
+// Enrollment:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain on
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Enrollment:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain off
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain on
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain off
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// The following tasks test OFFLINE TO ONLINE
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * User did not override any defaults
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * All online prefs set on the default branch
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain off
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user left on
+// * Data collection: user left off
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain on
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain off
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain on
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user turned off
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain off
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user left on
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain on
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain off
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// The following tasks test ONLINE TO ONLINE
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * User did not override any defaults
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * All online prefs set on the default branch
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain off
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user left on
+// * Data collection: user left off
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain on
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain off
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain on
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user turned off
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain off
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user left on
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain on
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user turned on
+//
+// Enrollment:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: remain off
+// * Data collection: remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ },
+ expectedPrefs: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// The following tasks test scenarios in conjunction with individual Nimbus
+// variables
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * User did not override any defaults
+//
+// Enrollment:
+// * Offline
+// * Sponsored suggestions individually forced on
+//
+// Expected:
+// * Sponsored suggestions: on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ quickSuggestSponsoredEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Sponsored suggestions: user turned off
+//
+// Enrollment:
+// * Offline
+// * Sponsored suggestions individually forced on
+//
+// Expected:
+// * Sponsored suggestions: remain off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ quickSuggestSponsoredEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * User did not override any defaults
+//
+// Enrollment:
+// * Offline
+// * Data collection individually forced on
+//
+// Expected:
+// * Data collection: on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ quickSuggestDataCollectionEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Offline (suggestions on and data collection off by default)
+// * Data collection: user turned off (it's off by default, so this simulates
+// when the user toggled it on and then back off)
+//
+// Enrollment:
+// * Offline
+// * Data collection individually forced on
+//
+// Expected:
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "offline",
+ quickSuggestDataCollectionEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * User did not override any defaults
+//
+// Enrollment:
+// * Online
+// * Sponsored suggestions individually forced off
+//
+// Expected:
+// * Sponsored suggestions: off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ quickSuggestSponsoredEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Sponsored suggestions: user turned on (they're on by default, so this
+// simulates when the user toggled them off and then back on)
+//
+// Enrollment:
+// * Online
+// * Sponsored suggestions individually forced off
+//
+// Expected:
+// * Sponsored suggestions: remain on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ quickSuggestSponsoredEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ userBranch: {
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * User did not override any defaults
+//
+// Enrollment:
+// * Online
+// * Data collection individually forced on
+//
+// Expected:
+// * Data collection: on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ quickSuggestDataCollectionEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Online (suggestions on and data collection off by default)
+// * Data collection: user turned off (it's off by default, so this simulates
+// when the user toggled it on and then back off)
+//
+// Enrollment:
+// * Online
+// * Data collection individually forced on
+//
+// Expected:
+// * Data collection: remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ quickSuggestDataCollectionEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ ...UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.online,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ });
+});
+
+// The following tasks test individual Nimbus variables without scenarios
+
+// Initial state:
+// * Suggestions on by default and user left them on
+//
+// 1. First enrollment:
+// * Suggestions forced off
+//
+// Expected:
+// * Suggestions off
+//
+// 2. User turns on suggestions
+// 3. Second enrollment:
+// * Suggestions forced off again
+//
+// Expected:
+// * Suggestions remain on
+add_task(async function () {
+ await checkEnrollments([
+ {
+ initialPrefsToSet: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestNonSponsoredEnabled: false,
+ quickSuggestSponsoredEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ },
+ {
+ initialPrefsToSet: {
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestNonSponsoredEnabled: false,
+ quickSuggestSponsoredEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ },
+ ]);
+});
+
+// Initial state:
+// * Suggestions on by default but user turned them off
+//
+// Enrollment:
+// * Suggestions forced on
+//
+// Expected:
+// * Suggestions remain off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestNonSponsoredEnabled: true,
+ quickSuggestSponsoredEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Suggestions off by default and user left them off
+//
+// 1. First enrollment:
+// * Suggestions forced on
+//
+// Expected:
+// * Suggestions on
+//
+// 2. User turns off suggestions
+// 3. Second enrollment:
+// * Suggestions forced on again
+//
+// Expected:
+// * Suggestions remain off
+add_task(async function () {
+ await checkEnrollments([
+ {
+ initialPrefsToSet: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestNonSponsoredEnabled: true,
+ quickSuggestSponsoredEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ },
+ {
+ initialPrefsToSet: {
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestNonSponsoredEnabled: true,
+ quickSuggestSponsoredEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ },
+ ]);
+});
+
+// Initial state:
+// * Suggestions off by default but user turned them on
+//
+// Enrollment:
+// * Suggestions forced off
+//
+// Expected:
+// * Suggestions remain on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestNonSponsoredEnabled: false,
+ quickSuggestSponsoredEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Data collection on by default and user left them on
+//
+// 1. First enrollment:
+// * Data collection forced off
+//
+// Expected:
+// * Data collection off
+//
+// 2. User turns on data collection
+// 3. Second enrollment:
+// * Data collection forced off again
+//
+// Expected:
+// * Data collection remains on
+add_task(async function () {
+ await checkEnrollments(
+ [
+ {
+ initialPrefsToSet: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestDataCollectionEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ },
+ ],
+ [
+ {
+ initialPrefsToSet: {
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestDataCollectionEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ },
+ ]
+ );
+});
+
+// Initial state:
+// * Data collection on by default but user turned it off
+//
+// Enrollment:
+// * Data collection forced on
+//
+// Expected:
+// * Data collection remains off
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestDataCollectionEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ });
+});
+
+// Initial state:
+// * Data collection off by default and user left it off
+//
+// 1. First enrollment:
+// * Data collection forced on
+//
+// Expected:
+// * Data collection on
+//
+// 2. User turns off data collection
+// 3. Second enrollment:
+// * Data collection forced on again
+//
+// Expected:
+// * Data collection remains off
+add_task(async function () {
+ await checkEnrollments(
+ [
+ {
+ initialPrefsToSet: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestDataCollectionEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ },
+ ],
+ [
+ {
+ initialPrefsToSet: {
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ valueOverrides: {
+ quickSuggestDataCollectionEnabled: true,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ },
+ ]
+ );
+});
+
+// Initial state:
+// * Data collection off by default but user turned it on
+//
+// Enrollment:
+// * Data collection forced off
+//
+// Expected:
+// * Data collection remains on
+add_task(async function () {
+ await checkEnrollments({
+ initialPrefsToSet: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ valueOverrides: {
+ quickSuggestDataCollectionEnabled: false,
+ },
+ expectedPrefs: {
+ defaultBranch: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+/**
+ * Tests one or more enrollments. Sets an initial set of prefs on the default
+ * and/or user branches, enrolls in a mock Nimbus experiment, checks expected
+ * pref values, unenrolls, and finally checks prefs again.
+ *
+ * The given `options` value may be an object as described below or an array of
+ * such objects, one per enrollment.
+ *
+ * @param {object} options
+ * Function options.
+ * @param {object} options.initialPrefsToSet
+ * An object: { userBranch, defaultBranch }
+ * `userBranch` and `defaultBranch` are objects that map pref names (relative
+ * to `browser.urlbar`) to values. These prefs will be set on the appropriate
+ * branch before enrollment. Both `userBranch` and `defaultBranch` are
+ * optional.
+ * @param {object} options.valueOverrides
+ * The `valueOverrides` object passed to the mock experiment. It should map
+ * Nimbus variable names to values.
+ * @param {object} options.expectedPrefs
+ * Preferences that should be set after enrollment. It has the same shape as
+ * `options.initialPrefsToSet`.
+ */
+async function checkEnrollments(options) {
+ info("Testing: " + JSON.stringify(options));
+
+ let enrollments;
+ if (Array.isArray(options)) {
+ enrollments = options;
+ } else {
+ enrollments = [options];
+ }
+
+ // Do each enrollment.
+ for (let i = 0; i < enrollments.length; i++) {
+ info(
+ `Starting setup for enrollment ${i}: ` + JSON.stringify(enrollments[i])
+ );
+
+ let { initialPrefsToSet, valueOverrides, expectedPrefs } = enrollments[i];
+
+ // Set initial prefs.
+ UrlbarPrefs._updatingFirefoxSuggestScenario = true;
+ let { defaultBranch: initialDefaultBranch, userBranch: initialUserBranch } =
+ initialPrefsToSet;
+ initialDefaultBranch = initialDefaultBranch || {};
+ initialUserBranch = initialUserBranch || {};
+ for (let name of Object.keys(initialDefaultBranch)) {
+ // Clear user-branch values on the default prefs so the defaults aren't
+ // masked.
+ gUserBranch.clearUserPref(name);
+ }
+ for (let [branch, prefs] of [
+ [gDefaultBranch, initialDefaultBranch],
+ [gUserBranch, initialUserBranch],
+ ]) {
+ for (let [name, value] of Object.entries(prefs)) {
+ branch.setBoolPref(name, value);
+ }
+ }
+ UrlbarPrefs._updatingFirefoxSuggestScenario = false;
+
+ let {
+ defaultBranch: expectedDefaultBranch,
+ userBranch: expectedUserBranch,
+ } = expectedPrefs;
+ expectedDefaultBranch = expectedDefaultBranch || {};
+ expectedUserBranch = expectedUserBranch || {};
+
+ // Install the experiment.
+ info(`Installing experiment for enrollment ${i}`);
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides,
+ callback: () => {
+ info(`Installed experiment for enrollment ${i}, now checking prefs`);
+
+ // 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 = {};
+ for (let [branch, prefs, branchType] of [
+ [gDefaultBranch, expectedDefaultBranch, "default"],
+ [gUserBranch, expectedUserBranch, "user"],
+ ]) {
+ for (let [name, value] of Object.entries(prefs)) {
+ expectedEffectivePrefs[name] = value;
+ Assert.equal(
+ branch.getBoolPref(name),
+ value,
+ `Pref ${name} on ${branchType} branch`
+ );
+ if (branch == gUserBranch) {
+ Assert.ok(
+ gUserBranch.prefHasUserValue(name),
+ `Pref ${name} is on user branch`
+ );
+ }
+ }
+ }
+ for (let name of Object.keys(initialDefaultBranch)) {
+ if (!expectedUserBranch.hasOwnProperty(name)) {
+ Assert.ok(
+ !gUserBranch.prefHasUserValue(name),
+ `Pref ${name} is not on user branch`
+ );
+ }
+ }
+ for (let [name, value] of Object.entries(expectedEffectivePrefs)) {
+ Assert.equal(
+ UrlbarPrefs.get(name),
+ value,
+ `Pref ${name} effective value`
+ );
+ }
+
+ info(`Uninstalling experiment for enrollment ${i}`);
+ },
+ });
+
+ info(`Uninstalled experiment for enrollment ${i}, now checking prefs`);
+
+ // Check expected effective values after unenrollment. The expected
+ // effective value for a pref at this point is the value on the user branch,
+ // or if there's not a user value, the original value on the default branch
+ // before enrollment. This assumes the default values reflect the offline
+ // scenario (the case for the U.S. region).
+ let effectivePrefs = Object.assign(
+ {},
+ UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS.offline
+ );
+ for (let [name, value] of Object.entries(expectedUserBranch)) {
+ effectivePrefs[name] = value;
+ }
+ for (let [name, value] of Object.entries(effectivePrefs)) {
+ Assert.equal(
+ UrlbarPrefs.get(name),
+ value,
+ `Pref ${name} effective value after unenrolling`
+ );
+ }
+
+ // Clean up.
+ UrlbarPrefs._updatingFirefoxSuggestScenario = true;
+ for (let name of Object.keys(expectedUserBranch)) {
+ UrlbarPrefs.clear(name);
+ }
+ UrlbarPrefs._updatingFirefoxSuggestScenario = false;
+ }
+}
+
+// The following tasks test enterprise preference policies
+
+// Preference policy test for the following:
+// * Status: locked
+// * Value: false
+add_task(async function () {
+ await doPolicyTest({
+ prefPolicy: {
+ Status: "locked",
+ Value: false,
+ },
+ expectedDefault: false,
+ expectedUser: undefined,
+ expectedLocked: true,
+ });
+});
+
+// Preference policy test for the following:
+// * Status: locked
+// * Value: true
+add_task(async function () {
+ await doPolicyTest({
+ prefPolicy: {
+ Status: "locked",
+ Value: true,
+ },
+ expectedDefault: true,
+ expectedUser: undefined,
+ expectedLocked: true,
+ });
+});
+
+// Preference policy test for the following:
+// * Status: default
+// * Value: false
+add_task(async function () {
+ await doPolicyTest({
+ prefPolicy: {
+ Status: "default",
+ Value: false,
+ },
+ expectedDefault: false,
+ expectedUser: undefined,
+ expectedLocked: false,
+ });
+});
+
+// Preference policy test for the following:
+// * Status: default
+// * Value: true
+add_task(async function () {
+ await doPolicyTest({
+ prefPolicy: {
+ Status: "default",
+ Value: true,
+ },
+ expectedDefault: true,
+ expectedUser: undefined,
+ expectedLocked: false,
+ });
+});
+
+// Preference policy test for the following:
+// * Status: user
+// * Value: false
+add_task(async function () {
+ await doPolicyTest({
+ prefPolicy: {
+ Status: "user",
+ Value: false,
+ },
+ expectedDefault: true,
+ expectedUser: false,
+ expectedLocked: false,
+ });
+});
+
+// Preference policy test for the following:
+// * Status: user
+// * Value: true
+add_task(async function () {
+ await doPolicyTest({
+ prefPolicy: {
+ Status: "user",
+ Value: true,
+ },
+ expectedDefault: true,
+ // Because the pref is sticky, it's true on the user branch even though it's
+ // also true on the default branch. Sticky prefs retain their user-branch
+ // values even when they're the same as their default-branch values.
+ expectedUser: true,
+ expectedLocked: false,
+ });
+});
+
+/**
+ * This tests an enterprise preference policy with one of the quick suggest
+ * sticky prefs (defined by `POLICY_PREF`). Pref policies should apply to the
+ * quick suggest sticky prefs just as they do to non-sticky prefs.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {object} options.prefPolicy
+ * An object `{ Status, Value }` that will be included in the policy.
+ * @param {boolean} options.expectedDefault
+ * The expected default-branch pref value after setting the policy.
+ * @param {boolean} options.expectedUser
+ * The expected user-branch pref value after setting the policy or undefined
+ * if the pref should not exist on the user branch.
+ * @param {boolean} options.expectedLocked
+ * Whether the pref is expected to be locked after setting the policy.
+ */
+async function doPolicyTest({
+ prefPolicy,
+ expectedDefault,
+ expectedUser,
+ expectedLocked,
+}) {
+ info(
+ "Starting pref policy test: " +
+ JSON.stringify({
+ prefPolicy,
+ expectedDefault,
+ expectedUser,
+ expectedLocked,
+ })
+ );
+
+ let pref = POLICY_PREF;
+
+ // Check initial state.
+ Assert.ok(
+ gDefaultBranch.getBoolPref(pref),
+ `${pref} is initially true on default branch (assuming en-US)`
+ );
+ Assert.ok(
+ !gUserBranch.prefHasUserValue(pref),
+ `${pref} does not have initial user value`
+ );
+
+ // Set up the policy.
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ Preferences: {
+ [`browser.urlbar.${pref}`]: prefPolicy,
+ },
+ },
+ });
+ Assert.equal(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.ACTIVE,
+ "Policy engine is active"
+ );
+
+ // Check the default branch.
+ Assert.equal(
+ gDefaultBranch.getBoolPref(pref),
+ expectedDefault,
+ `${pref} has expected default-branch value after setting policy`
+ );
+
+ // Check the user branch.
+ Assert.equal(
+ gUserBranch.prefHasUserValue(pref),
+ expectedUser !== undefined,
+ `${pref} is on user branch as expected after setting policy`
+ );
+ if (expectedUser !== undefined) {
+ Assert.equal(
+ gUserBranch.getBoolPref(pref),
+ expectedUser,
+ `${pref} has expected user-branch value after setting policy`
+ );
+ }
+
+ // Check the locked state.
+ Assert.equal(
+ gDefaultBranch.prefIsLocked(pref),
+ expectedLocked,
+ `${pref} is locked as expected after setting policy`
+ );
+
+ // Clean up.
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
+ Assert.equal(
+ Services.policies.status,
+ Ci.nsIEnterprisePolicies.INACTIVE,
+ "Policy engine is inactive"
+ );
+
+ gDefaultBranch.unlockPref(pref);
+ gUserBranch.clearUserPref(pref);
+ await QuickSuggestTestUtils.setScenario(null);
+
+ Assert.ok(
+ !gDefaultBranch.prefIsLocked(pref),
+ `${pref} is not locked after cleanup`
+ );
+ Assert.ok(
+ gDefaultBranch.getBoolPref(pref),
+ `${pref} is true on default branch after cleanup (assuming en-US)`
+ );
+ Assert.ok(
+ !gUserBranch.prefHasUserValue(pref),
+ `${pref} does not have user value after cleanup`
+ );
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js
new file mode 100644
index 0000000000..282e1a2ba0
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js
@@ -0,0 +1,425 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests the configurable indexes of sponsored and non-sponsored ("Firefox
+// Suggest") quick suggest results.
+
+"use strict";
+
+const SUGGESTIONS_FIRST_PREF = "browser.urlbar.showSearchSuggestionsFirst";
+const SUGGESTIONS_PREF = "browser.urlbar.suggest.searches";
+
+const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
+const MAX_RESULTS = UrlbarPrefs.get("maxRichResults");
+
+const SPONSORED_INDEX_PREF = "browser.urlbar.quicksuggest.sponsoredIndex";
+const NON_SPONSORED_INDEX_PREF =
+ "browser.urlbar.quicksuggest.nonSponsoredIndex";
+
+const SPONSORED_SEARCH_STRING = "frabbits";
+const NON_SPONSORED_SEARCH_STRING = "nonspon";
+
+const TEST_URL = "http://example.com/quicksuggest";
+
+const REMOTE_SETTINGS_RESULTS = [
+ {
+ id: 1,
+ url: `${TEST_URL}?q=${SPONSORED_SEARCH_STRING}`,
+ title: "frabbits",
+ keywords: [SPONSORED_SEARCH_STRING],
+ click_url: "http://click.reporting.test.com/",
+ impression_url: "http://impression.reporting.test.com/",
+ advertiser: "TestAdvertiser",
+ },
+ {
+ id: 2,
+ url: `${TEST_URL}?q=${NON_SPONSORED_SEARCH_STRING}`,
+ title: "Non-Sponsored",
+ keywords: [NON_SPONSORED_SEARCH_STRING],
+ click_url: "http://click.reporting.test.com/nonsponsored",
+ impression_url: "http://impression.reporting.test.com/nonsponsored",
+ advertiser: "TestAdvertiserNonSponsored",
+ iab_category: "5 - Education",
+ },
+];
+
+add_setup(async function () {
+ // This test intermittently times out on Mac TV WebRender.
+ if (AppConstants.platform == "macosx") {
+ requestLongerTimeout(3);
+ }
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ // Add a mock engine so we don't hit the network.
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsResults: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ await PlacesUtils.history.clear();
+ });
+});
+
+// Tests with history only
+add_task(async function noSuggestions() {
+ await doTestPermutations(({ withHistory, generalIndex }) => ({
+ expectedResultCount: withHistory ? MAX_RESULTS : 2,
+ expectedIndex: generalIndex == 0 || !withHistory ? 1 : MAX_RESULTS - 1,
+ }));
+});
+
+// Tests with suggestions followed by history
+add_task(async function suggestionsFirst() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SUGGESTIONS_FIRST_PREF, true]],
+ });
+ await withSuggestions(async () => {
+ await doTestPermutations(({ withHistory, generalIndex }) => ({
+ expectedResultCount: withHistory ? MAX_RESULTS : 4,
+ expectedIndex: generalIndex == 0 || !withHistory ? 3 : MAX_RESULTS - 1,
+ }));
+ });
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests with history followed by suggestions
+add_task(async function suggestionsLast() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SUGGESTIONS_FIRST_PREF, false]],
+ });
+ await withSuggestions(async () => {
+ await doTestPermutations(({ withHistory, generalIndex }) => ({
+ expectedResultCount: withHistory ? MAX_RESULTS : 4,
+ expectedIndex: generalIndex == 0 || !withHistory ? 1 : MAX_RESULTS - 3,
+ }));
+ });
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests with history only plus a suggestedIndex result with a resultSpan
+add_task(async function otherSuggestedIndex_noSuggestions() {
+ await doSuggestedIndexTest([
+ // heuristic
+ { heuristic: true },
+ // TestProvider result
+ { suggestedIndex: 1, resultSpan: 2 },
+ // history
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ // quick suggest
+ {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ providerName: UrlbarProviderQuickSuggest.name,
+ },
+ ]);
+});
+
+// Tests with suggestions followed by history plus a suggestedIndex result with
+// a resultSpan
+add_task(async function otherSuggestedIndex_suggestionsFirst() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SUGGESTIONS_FIRST_PREF, true]],
+ });
+ await withSuggestions(async () => {
+ await doSuggestedIndexTest([
+ // heuristic
+ { heuristic: true },
+ // TestProvider result
+ { suggestedIndex: 1, resultSpan: 2 },
+ // search suggestions
+ {
+ type: UrlbarUtils.RESULT_TYPE.SEARCH,
+ payload: { suggestion: SPONSORED_SEARCH_STRING + "foo" },
+ },
+ {
+ type: UrlbarUtils.RESULT_TYPE.SEARCH,
+ payload: { suggestion: SPONSORED_SEARCH_STRING + "bar" },
+ },
+ // history
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ // quick suggest
+ {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ providerName: UrlbarProviderQuickSuggest.name,
+ },
+ ]);
+ });
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests with history followed by suggestions plus a suggestedIndex result with
+// a resultSpan
+add_task(async function otherSuggestedIndex_suggestionsLast() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SUGGESTIONS_FIRST_PREF, false]],
+ });
+ await withSuggestions(async () => {
+ await doSuggestedIndexTest([
+ // heuristic
+ { heuristic: true },
+ // TestProvider result
+ { suggestedIndex: 1, resultSpan: 2 },
+ // history
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ { type: UrlbarUtils.RESULT_TYPE.URL },
+ // quick suggest
+ {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ providerName: UrlbarProviderQuickSuggest.name,
+ },
+ // search suggestions
+ {
+ type: UrlbarUtils.RESULT_TYPE.SEARCH,
+ payload: { suggestion: SPONSORED_SEARCH_STRING + "foo" },
+ },
+ {
+ type: UrlbarUtils.RESULT_TYPE.SEARCH,
+ payload: { suggestion: SPONSORED_SEARCH_STRING + "bar" },
+ },
+ ]);
+ });
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * A test provider that returns one result with a suggestedIndex and resultSpan.
+ */
+class TestProvider extends UrlbarTestUtils.TestProvider {
+ constructor() {
+ super({
+ results: [
+ Object.assign(
+ new UrlbarResult(
+ UrlbarUtils.RESULT_TYPE.URL,
+ UrlbarUtils.RESULT_SOURCE.OTHER_LOCAL,
+ { url: "http://example.com/test" }
+ ),
+ {
+ suggestedIndex: 1,
+ resultSpan: 2,
+ }
+ ),
+ ],
+ });
+ }
+}
+
+/**
+ * Does a round of test permutations.
+ *
+ * @param {Function} callback
+ * For each permutation, this will be called with the arguments of `doTest()`,
+ * and it should return an object with the appropriate values of
+ * `expectedResultCount` and `expectedIndex`.
+ */
+async function doTestPermutations(callback) {
+ for (let isSponsored of [true, false]) {
+ for (let withHistory of [true, false]) {
+ for (let generalIndex of [0, -1]) {
+ let opts = {
+ isSponsored,
+ withHistory,
+ generalIndex,
+ };
+ await doTest(Object.assign(opts, callback(opts)));
+ }
+ }
+ }
+}
+
+/**
+ * Does one test run.
+ *
+ * @param {object} options
+ * Options for the test.
+ * @param {boolean} options.isSponsored
+ * True to use a sponsored result, false to use a non-sponsored result.
+ * @param {boolean} options.withHistory
+ * True to run with a bunch of history, false to run with no history.
+ * @param {number} options.generalIndex
+ * The value to set as the relevant index pref, i.e., the index within the
+ * general group of the quick suggest result.
+ * @param {number} options.expectedResultCount
+ * The expected total result count for sanity checking.
+ * @param {number} options.expectedIndex
+ * The expected index of the quick suggest result in the whole results list.
+ */
+async function doTest({
+ isSponsored,
+ withHistory,
+ generalIndex,
+ expectedResultCount,
+ expectedIndex,
+}) {
+ info(
+ "Running test with options: " +
+ JSON.stringify({
+ isSponsored,
+ withHistory,
+ generalIndex,
+ expectedResultCount,
+ expectedIndex,
+ })
+ );
+
+ // Set the index pref.
+ let indexPref = isSponsored ? SPONSORED_INDEX_PREF : NON_SPONSORED_INDEX_PREF;
+ await SpecialPowers.pushPrefEnv({
+ set: [[indexPref, generalIndex]],
+ });
+
+ // Add history.
+ if (withHistory) {
+ await addHistory();
+ }
+
+ // Do a search.
+ let value = isSponsored
+ ? SPONSORED_SEARCH_STRING
+ : NON_SPONSORED_SEARCH_STRING;
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value,
+ });
+
+ // Check the result count and quick suggest result.
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ expectedResultCount,
+ "Expected result count"
+ );
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ isSponsored,
+ index: expectedIndex,
+ url: isSponsored
+ ? `${TEST_URL}?q=${SPONSORED_SEARCH_STRING}`
+ : `${TEST_URL}?q=${NON_SPONSORED_SEARCH_STRING}`,
+ });
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await PlacesUtils.history.clear();
+ await SpecialPowers.popPrefEnv();
+}
+
+/**
+ * Adds history that matches the sponsored and non-sponsored search strings.
+ */
+async function addHistory() {
+ for (let i = 0; i < MAX_RESULTS; i++) {
+ await PlacesTestUtils.addVisits([
+ "http://example.com/" + SPONSORED_SEARCH_STRING + i,
+ "http://example.com/" + NON_SPONSORED_SEARCH_STRING + i,
+ ]);
+ }
+}
+
+/**
+ * Adds a search engine that provides suggestions, calls your callback, and then
+ * removes the engine.
+ *
+ * @param {Function} callback
+ * Your callback function.
+ */
+async function withSuggestions(callback) {
+ await SpecialPowers.pushPrefEnv({
+ set: [[SUGGESTIONS_PREF, true]],
+ });
+ let engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME,
+ });
+ let oldDefaultEngine = await Services.search.getDefault();
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ try {
+ await callback(engine);
+ } finally {
+ await Services.search.setDefault(
+ oldDefaultEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ await Services.search.removeEngine(engine);
+ await SpecialPowers.popPrefEnv();
+ }
+}
+
+/**
+ * Registers a test provider that returns a result with a suggestedIndex and
+ * resultSpan and asserts the given expected results match the actual results.
+ *
+ * @param {Array} expectedProps
+ * See `checkResults()`.
+ */
+async function doSuggestedIndexTest(expectedProps) {
+ await addHistory();
+ let provider = new TestProvider();
+ UrlbarProvidersManager.registerProvider(provider);
+
+ let context = await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: SPONSORED_SEARCH_STRING,
+ });
+ checkResults(context.results, expectedProps);
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ UrlbarProvidersManager.unregisterProvider(provider);
+ await PlacesUtils.history.clear();
+}
+
+/**
+ * Asserts the given actual and expected results match.
+ *
+ * @param {Array} actualResults
+ * Array of actual results.
+ * @param {Array} expectedProps
+ * Array of expected result-like objects. Only the properties defined in each
+ * of these objects are compared against the corresponding actual result.
+ */
+function checkResults(actualResults, expectedProps) {
+ Assert.equal(
+ actualResults.length,
+ expectedProps.length,
+ "Expected result count"
+ );
+
+ let actualProps = actualResults.map((actual, i) => {
+ if (expectedProps.length <= i) {
+ return actual;
+ }
+ let props = {};
+ let expected = expectedProps[i];
+ for (let [key, expectedValue] of Object.entries(expected)) {
+ if (key != "payload") {
+ props[key] = actual[key];
+ } else {
+ props.payload = {};
+ for (let pkey of Object.keys(expectedValue)) {
+ props.payload[pkey] = actual.payload[pkey];
+ }
+ }
+ }
+ return props;
+ });
+ Assert.deepEqual(actualProps, expectedProps);
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js
new file mode 100644
index 0000000000..050ea31e12
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// End-to-end browser smoke test for Merino sessions. More comprehensive tests
+// are in test_quicksuggest_merinoSessions.js. This test essentially makes sure
+// engagements occur as expected when interacting with the urlbar. If you need
+// to add tests that do not depend on a new definition of "engagement", consider
+// adding them to test_quicksuggest_merinoSessions.js instead.
+
+"use strict";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.merino.enabled", true],
+ ["browser.urlbar.quicksuggest.remoteSettings.enabled", false],
+ ["browser.urlbar.quicksuggest.dataCollection.enabled", true],
+ ],
+ });
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ // Install a mock default engine so we don't hit the network.
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ await MerinoTestUtils.server.start();
+});
+
+// In a single engagement, all requests should use the same session ID and the
+// sequence number should be incremented.
+add_task(async function singleEngagement() {
+ for (let i = 0; i < 3; i++) {
+ let searchString = "search" + i;
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchString,
+ fireInputEvent: true,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i,
+ },
+ },
+ ]);
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur());
+});
+
+// In a single engagement, all requests should use the same session ID and the
+// sequence number should be incremented. This task closes the panel between
+// searches but keeps the input focused, so the engagement should not end.
+add_task(async function singleEngagement_panelClosed() {
+ for (let i = 0; i < 3; i++) {
+ let searchString = "search" + i;
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchString,
+ fireInputEvent: true,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i,
+ },
+ },
+ ]);
+
+ EventUtils.synthesizeKey("KEY_Escape");
+ Assert.ok(!UrlbarTestUtils.isPopupOpen(window), "Panel is closed");
+ Assert.ok(gURLBar.focused, "Input remains focused");
+ }
+
+ // End the engagement to reset the session for the next test.
+ gURLBar.blur();
+});
+
+// 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() {
+ for (let i = 0; i < 3; i++) {
+ // Open a new tab since we'll load the mock default search engine page.
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ let searchString = "search" + i;
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchString,
+ fireInputEvent: true,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ },
+ },
+ ]);
+
+ // Press enter on the heuristic result to load the search engine page and
+ // complete the engagement.
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ await loadPromise;
+ });
+ }
+});
+
+// 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() {
+ for (let i = 0; i < 3; i++) {
+ let searchString = "search" + i;
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchString,
+ fireInputEvent: true,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ },
+ },
+ ]);
+
+ // Blur the urlbar to abandon the engagement.
+ await UrlbarTestUtils.promisePopupClose(window, () => gURLBar.blur());
+ }
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js
new file mode 100644
index 0000000000..92c3c7c95d
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js
@@ -0,0 +1,1596 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the buttons in the onboarding dialog for quick suggest/Firefox Suggest.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+});
+
+const OTHER_DIALOG_URI = getRootDirectory(gTestPath) + "subdialog.xhtml";
+
+// Default-branch pref values in the offline scenario.
+const OFFLINE_DEFAULT_PREFS = {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": false,
+};
+
+let gDefaultBranch = Services.prefs.getDefaultBranch("browser.urlbar.");
+let gUserBranch = Services.prefs.getBranch("browser.urlbar.");
+
+// Allow more time for Mac and Linux machines so they don't time out in verify mode.
+if (AppConstants.platform === "macosx") {
+ requestLongerTimeout(4);
+} else if (AppConstants.platform === "linux") {
+ requestLongerTimeout(2);
+}
+
+// Whether the tab key can move the focus. On macOS with full keyboard access
+// disabled (which is default), this will be false. See `canTabMoveFocus`.
+let gCanTabMoveFocus;
+add_setup(async function () {
+ gCanTabMoveFocus = await canTabMoveFocus();
+});
+
+// When the user has already enabled the data-collection pref, the dialog should
+// not appear.
+add_task(async function dataCollectionAlreadyEnabled() {
+ setDialogPrereqPrefs();
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true);
+
+ info("Calling maybeShowOnboardingDialog");
+ let showed = await QuickSuggest.maybeShowOnboardingDialog();
+ Assert.ok(!showed, "The dialog was not shown");
+
+ UrlbarPrefs.clear("quicksuggest.dataCollection.enabled");
+});
+
+// When the current tab is about:welcome, the dialog should not appear.
+add_task(async function aboutWelcome() {
+ setDialogPrereqPrefs();
+ await BrowserTestUtils.withNewTab("about:welcome", async () => {
+ info("Calling maybeShowOnboardingDialog");
+ let showed = await QuickSuggest.maybeShowOnboardingDialog();
+ Assert.ok(!showed, "The dialog was not shown");
+ });
+});
+
+// The Escape key should dismiss the dialog without opting in. This task tests
+// when Escape is pressed while the focus is inside the dialog.
+add_task(async function escKey_focusInsideDialog() {
+ await doDialogTest({
+ callback: async () => {
+ const { maybeShowPromise } = await showOnboardingDialog({
+ skipIntroduction: true,
+ });
+
+ const tabCount = gBrowser.tabs.length;
+ Assert.ok(
+ document.activeElement.classList.contains("dialogFrame"),
+ "dialogFrame is focused in the browser window"
+ );
+
+ info("Close the dialog");
+ EventUtils.synthesizeKey("KEY_Escape");
+
+ await maybeShowPromise;
+
+ Assert.equal(
+ gBrowser.currentURI.spec,
+ "about:blank",
+ "Nothing loaded in the current tab"
+ );
+ Assert.equal(gBrowser.tabs.length, tabCount, "No news tabs were opened");
+ },
+ onboardingDialogVersion: JSON.stringify({ version: 1 }),
+ onboardingDialogChoice: "dismiss_2",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "dismiss_2",
+ },
+ ],
+ });
+});
+
+// The Escape key should dismiss the dialog without opting in. This task tests
+// when Escape is pressed while the focus is outside the dialog.
+add_task(async function escKey_focusOutsideDialog() {
+ await doDialogTest({
+ callback: async () => {
+ const { maybeShowPromise } = await showOnboardingDialog({
+ skipIntroduction: true,
+ });
+
+ document.documentElement.focus();
+ Assert.ok(
+ !document.activeElement.classList.contains("dialogFrame"),
+ "dialogFrame is not focused in the browser window"
+ );
+
+ info("Close the dialog");
+ EventUtils.synthesizeKey("KEY_Escape");
+
+ await maybeShowPromise;
+ },
+ onboardingDialogVersion: JSON.stringify({ version: 1 }),
+ onboardingDialogChoice: "dismiss_2",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "dismiss_2",
+ },
+ ],
+ });
+});
+
+// The Escape key should dismiss the dialog without opting in when another
+// dialog is queued and shown before the onboarding. This task dismisses the
+// other dialog by pressing the Escape key.
+add_task(async function escKey_queued_esc() {
+ await doQueuedEscKeyTest("KEY_Escape");
+});
+
+// The Escape key should dismiss the dialog without opting in when another
+// dialog is queued and shown before the onboarding. This task dismisses the
+// other dialog by pressing the Enter key.
+add_task(async function escKey_queued_enter() {
+ await doQueuedEscKeyTest("KEY_Enter");
+});
+
+async function doQueuedEscKeyTest(otherDialogKey) {
+ await doDialogTest({
+ callback: async () => {
+ // Create promises that will resolve when each dialog is opened.
+ let uris = [OTHER_DIALOG_URI, QuickSuggest.ONBOARDING_URI];
+ let [otherOpenedPromise, onboardingOpenedPromise] = uris.map(uri =>
+ TestUtils.topicObserved(
+ "subdialog-loaded",
+ contentWin => contentWin.document.documentURI == uri
+ ).then(async ([contentWin]) => {
+ if (contentWin.document.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(contentWin, "load");
+ }
+ })
+ );
+
+ info("Queuing dialogs for opening");
+ let otherClosedPromise = gDialogBox.open(OTHER_DIALOG_URI);
+ let onboardingClosedPromise = QuickSuggest.maybeShowOnboardingDialog();
+
+ info("Waiting for the other dialog to open");
+ await otherOpenedPromise;
+
+ info(`Pressing ${otherDialogKey} and waiting for other dialog to close`);
+ EventUtils.synthesizeKey(otherDialogKey);
+ await otherClosedPromise;
+
+ info("Waiting for the onboarding dialog to open");
+ await onboardingOpenedPromise;
+
+ info("Pressing Escape and waiting for onboarding dialog to close");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await onboardingClosedPromise;
+ },
+ onboardingDialogVersion: JSON.stringify({ version: 1 }),
+ onboardingDialogChoice: "dismiss_1",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "dismiss_1",
+ },
+ ],
+ });
+}
+
+// Tests `dismissed_other` by closing the dialog programmatically.
+add_task(async function dismissed_other_on_introduction() {
+ await doDialogTest({
+ callback: async () => {
+ const { maybeShowPromise } = await showOnboardingDialog();
+ gDialogBox._dialog.close();
+ await maybeShowPromise;
+ },
+ onboardingDialogVersion: JSON.stringify({ version: 1 }),
+ onboardingDialogChoice: "dismiss_1",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "dismiss_1",
+ },
+ ],
+ });
+});
+
+// The default is to wait for no browser restarts to show the onboarding dialog
+// on the first restart. This tests that we can override it by configuring the
+// `showOnboardingDialogOnNthRestart`
+add_task(async function nimbus_override_wait_after_n_restarts() {
+ UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.seenRestarts", 0);
+
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ // Wait for 1 browser restart
+ quickSuggestShowOnboardingDialogAfterNRestarts: 1,
+ },
+ callback: async () => {
+ let prefPromise = TestUtils.waitForPrefChange(
+ "browser.urlbar.quicksuggest.showedOnboardingDialog",
+ value => value === true
+ ).then(() => info("Saw pref change"));
+
+ // Simulate 2 restarts. this function is only called by BrowserGlue
+ // on startup, the first restart would be where MR1 was shown then
+ // we will show onboarding the 2nd restart after that.
+ info("Simulating first restart");
+ await QuickSuggest.maybeShowOnboardingDialog();
+
+ info("Simulating second restart");
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ QuickSuggest.ONBOARDING_URI,
+ { isSubDialog: true }
+ );
+ const maybeShowPromise = QuickSuggest.maybeShowOnboardingDialog();
+ const win = await dialogPromise;
+ if (win.document.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ }
+ // Close dialog.
+ EventUtils.synthesizeKey("KEY_Escape");
+
+ info("Waiting for maybeShowPromise and pref change");
+ await Promise.all([maybeShowPromise, prefPromise]);
+ },
+ });
+});
+
+add_task(async function nimbus_skip_onboarding_dialog() {
+ UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.seenRestarts", 0);
+
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ quickSuggestShouldShowOnboardingDialog: false,
+ },
+ callback: async () => {
+ // Simulate 3 restarts.
+ for (let i = 0; i < 3; i++) {
+ info(`Simulating restart ${i + 1}`);
+ await QuickSuggest.maybeShowOnboardingDialog();
+ }
+ Assert.ok(
+ !Services.prefs.getBoolPref(
+ "browser.urlbar.quicksuggest.showedOnboardingDialog",
+ false
+ ),
+ "The showed onboarding dialog pref should not be set"
+ );
+ },
+ });
+});
+
+add_task(async function nimbus_exposure_event() {
+ const testData = [
+ {
+ experimentType: "modal",
+ expectedRecorded: true,
+ },
+ {
+ experimentType: "best-match",
+ expectedRecorded: false,
+ },
+ {
+ expectedRecorded: false,
+ },
+ ];
+
+ for (const { experimentType, expectedRecorded } of testData) {
+ info(`Nimbus exposure event test for type:[${experimentType}]`);
+ UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.seenRestarts", 0);
+
+ await QuickSuggestTestUtils.clearExposureEvent();
+
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ experimentType,
+ },
+ callback: async () => {
+ info("Calling showOnboardingDialog");
+ const { maybeShowPromise } = await showOnboardingDialog();
+ EventUtils.synthesizeKey("KEY_Escape");
+ await maybeShowPromise;
+
+ info("Check the event");
+ await QuickSuggestTestUtils.assertExposureEvent(expectedRecorded);
+ },
+ });
+ }
+});
+
+const LOGO_TYPE = {
+ FIREFOX: 1,
+ MAGGLASS: 2,
+ ANIMATION_MAGGLASS: 3,
+};
+
+const VARIATION_TEST_DATA = [
+ {
+ name: "A",
+ introductionSection: {
+ logoType: LOGO_TYPE.ANIMATION_MAGGLASS,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-1",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ defaultFocusOrder: [
+ "onboardingNext",
+ "onboardingClose",
+ "onboardingNext",
+ ],
+ actions: ["onboardingClose", "onboardingNext"],
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.MAGGLASS,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-1",
+ "main-description": "firefox-suggest-onboarding-main-description-1",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-1",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-1",
+ },
+ visibility: {
+ "#main-privacy-first": false,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ defaultFocusOrder: [
+ "onboardingNext",
+ "onboardingAccept",
+ "onboardingLearnMore",
+ "onboardingReject",
+ "onboardingSkipLink",
+ "onboardingAccept",
+ ],
+ acceptFocusOrder: [
+ "onboardingAccept",
+ "onboardingLearnMore",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingAccept",
+ ],
+ rejectFocusOrder: [
+ "onboardingReject",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingLearnMore",
+ "onboardingReject",
+ ],
+ actions: [
+ "onboardingAccept",
+ "onboardingReject",
+ "onboardingSkipLink",
+ "onboardingLearnMore",
+ ],
+ },
+ },
+ {
+ // We don't need to test the focus order and actions because the layout of
+ // variation B-H is as same as A.
+ name: "B",
+ introductionSection: {
+ logoType: LOGO_TYPE.ANIMATION_MAGGLASS,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-2",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.MAGGLASS,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-2",
+ "main-description": "firefox-suggest-onboarding-main-description-2",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-1",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-1",
+ },
+ visibility: {
+ "#main-privacy-first": false,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ },
+ },
+ {
+ name: "C",
+ introductionSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-3",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-3",
+ "main-description": "firefox-suggest-onboarding-main-description-3",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-1",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-1",
+ },
+ visibility: {
+ "#main-privacy-first": false,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ },
+ },
+ {
+ name: "D",
+ introductionSection: {
+ logoType: LOGO_TYPE.ANIMATION_MAGGLASS,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-4",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.MAGGLASS,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-4",
+ "main-description": "firefox-suggest-onboarding-main-description-4",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-2",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-2",
+ },
+ visibility: {
+ "#main-privacy-first": false,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ },
+ },
+ {
+ name: "E",
+ introductionSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-5",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-5",
+ "main-description": "firefox-suggest-onboarding-main-description-5",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-2",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-2",
+ },
+ visibility: {
+ "#main-privacy-first": false,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ },
+ },
+ {
+ name: "F",
+ introductionSection: {
+ logoType: LOGO_TYPE.ANIMATION_MAGGLASS,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-2",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-6",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.MAGGLASS,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-6",
+ "main-description": "firefox-suggest-onboarding-main-description-6",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-2",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-2",
+ },
+ visibility: {
+ "#main-privacy-first": false,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ },
+ },
+ {
+ name: "G",
+ introductionSection: {
+ logoType: LOGO_TYPE.ANIMATION_MAGGLASS,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-7",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.MAGGLASS,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-7",
+ "main-description": "firefox-suggest-onboarding-main-description-7",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-2",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-2",
+ },
+ visibility: {
+ "#main-privacy-first": true,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ },
+ },
+ {
+ name: "H",
+ introductionSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-1",
+ "introduction-title": "firefox-suggest-onboarding-introduction-title-2",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": false,
+ ".description-section": false,
+ ".pager": true,
+ },
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-8",
+ "main-description": "firefox-suggest-onboarding-main-description-8",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-1",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-1",
+ },
+ visibility: {
+ "#main-privacy-first": false,
+ ".description-section #onboardingLearnMore": false,
+ ".accept #onboardingLearnMore": true,
+ ".pager": true,
+ },
+ },
+ },
+ {
+ name: "100-A",
+ introductionSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ onboardingNext: "firefox-suggest-onboarding-introduction-next-button-3",
+ "introduction-title": "firefox-suggest-onboarding-main-title-9",
+ },
+ visibility: {
+ "#onboardingLearnMoreOnIntroduction": true,
+ ".description-section": true,
+ ".pager": true,
+ },
+ defaultFocusOrder: [
+ "onboardingNext",
+ "onboardingLearnMoreOnIntroduction",
+ "onboardingClose",
+ "onboardingNext",
+ ],
+ actions: [
+ "onboardingClose",
+ "onboardingNext",
+ "onboardingLearnMoreOnIntroduction",
+ ],
+ },
+ mainSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-9",
+ "main-description": "firefox-suggest-onboarding-main-description-9",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label-2",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-3",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label-2",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-3",
+ },
+ visibility: {
+ "#main-privacy-first": true,
+ ".description-section #onboardingLearnMore": true,
+ ".accept #onboardingLearnMore": false,
+ ".pager": false,
+ },
+ defaultFocusOrder: [
+ "onboardingNext",
+ "onboardingLearnMore",
+ "onboardingAccept",
+ "onboardingReject",
+ "onboardingSkipLink",
+ "onboardingLearnMore",
+ ],
+ acceptFocusOrder: [
+ "onboardingAccept",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingLearnMore",
+ "onboardingAccept",
+ ],
+ rejectFocusOrder: [
+ "onboardingReject",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingLearnMore",
+ "onboardingReject",
+ ],
+ actions: [
+ "onboardingAccept",
+ "onboardingReject",
+ "onboardingSkipLink",
+ "onboardingLearnMore",
+ ],
+ },
+ },
+ {
+ name: "100-B",
+ mainSection: {
+ logoType: LOGO_TYPE.FIREFOX,
+ l10n: {
+ "main-title": "firefox-suggest-onboarding-main-title-9",
+ "main-description": "firefox-suggest-onboarding-main-description-9",
+ "main-accept-option-label":
+ "firefox-suggest-onboarding-main-accept-option-label-2",
+ "main-accept-option-description":
+ "firefox-suggest-onboarding-main-accept-option-description-3",
+ "main-reject-option-label":
+ "firefox-suggest-onboarding-main-reject-option-label-2",
+ "main-reject-option-description":
+ "firefox-suggest-onboarding-main-reject-option-description-3",
+ },
+ visibility: {
+ "#main-privacy-first": true,
+ ".description-section #onboardingLearnMore": true,
+ ".accept #onboardingLearnMore": false,
+ ".pager": false,
+ },
+ // Layout of 100-B is same as 100-A, but since there is no the introduction
+ // pane, only the default focus order on the main pane is a bit diffrence.
+ defaultFocusOrder: [
+ "onboardingLearnMore",
+ "onboardingAccept",
+ "onboardingReject",
+ "onboardingSkipLink",
+ "onboardingLearnMore",
+ ],
+ },
+ },
+];
+
+/**
+ * This test checks for differences due to variations in logo type, l10n text,
+ * element visibility, order of focus, actions, etc. The designation is on
+ * VARIATION_TEST_DATA. The items that can be specified are below.
+ *
+ * name: Specify the variation name.
+ *
+ * The following items are specified for each section.
+ * (introductionSection, mainSection).
+ *
+ * logoType:
+ * Specify the expected logo type. Please refer to LOGO_TYPE about the type.
+ *
+ * l10n:
+ * Specify the expected l10n id applied to elements.
+ *
+ * visibility:
+ * Specify the expected visibility of elements. The way to specify the element
+ * is using selector.
+ *
+ * defaultFocusOrder:
+ * Specify the expected focus order right after the section is appeared. The
+ * way to specify the element is using id.
+ *
+ * acceptFocusOrder:
+ * Specify the expected focus order after selecting accept option.
+ *
+ * rejectFocusOrder:
+ * Specify the expected focus order after selecting reject option.
+ *
+ * actions:
+ * Specify the action we want to verify such as clicking the close button. The
+ * available actions are below.
+ * - onboardingClose:
+ * Action of the close button “x” by mouse/keyboard.
+ * - onboardingNext:
+ * Action of the next button that transits from the introduction section to
+ * the main section by mouse/keyboard.
+ * - onboardingAccept:
+ * Action of the submit button by mouse/keyboard after selecting accept
+ * option by mouse/keyboard.
+ * - onboardingReject:
+ * Action of the submit button by mouse/keyboard after selecting reject
+ * option by mouse/keyboard.
+ * - onboardingSkipLink:
+ * Action of the skip link by mouse/keyboard.
+ * - onboardingLearnMore:
+ * Action of the learn more link by mouse/keyboard.
+ * - onboardingLearnMoreOnIntroduction:
+ * Action of the learn more link on the introduction section by
+ * mouse/keyboard.
+ */
+add_task(async function variation_test() {
+ for (const variation of VARIATION_TEST_DATA) {
+ info(`Test for variation [${variation.name}]`);
+
+ info("Do layout test");
+ await doLayoutTest(variation);
+
+ for (const action of variation.introductionSection?.actions || []) {
+ info(
+ `${action} test on the introduction section for variation [${variation.name}]`
+ );
+ await this[action](variation);
+ }
+
+ for (const action of variation.mainSection?.actions || []) {
+ info(
+ `${action} test on the main section for variation [${variation.name}]`
+ );
+ await this[action](variation, !!variation.introductionSection);
+ }
+ }
+});
+
+async function doLayoutTest(variation) {
+ UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.seenRestarts", 0);
+
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ quickSuggestOnboardingDialogVariation: variation.name,
+ },
+ callback: async () => {
+ info("Calling showOnboardingDialog");
+ const { win, maybeShowPromise } = await showOnboardingDialog();
+
+ const introductionSection = win.document.getElementById(
+ "introduction-section"
+ );
+ const mainSection = win.document.getElementById("main-section");
+
+ if (variation.introductionSection) {
+ info("Check the section visibility");
+ Assert.ok(BrowserTestUtils.is_visible(introductionSection));
+ Assert.ok(BrowserTestUtils.is_hidden(mainSection));
+
+ info("Check the introduction section");
+ await assertSection(introductionSection, variation.introductionSection);
+
+ info("Transition to the main section");
+ win.document.getElementById("onboardingNext").click();
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ BrowserTestUtils.is_hidden(introductionSection) &&
+ BrowserTestUtils.is_visible(mainSection)
+ );
+ } else {
+ info("Check the section visibility");
+ Assert.ok(BrowserTestUtils.is_hidden(introductionSection));
+ Assert.ok(BrowserTestUtils.is_visible(mainSection));
+ }
+
+ info("Check the main section");
+ await assertSection(mainSection, variation.mainSection);
+
+ info("Close the dialog");
+ EventUtils.synthesizeKey("KEY_Escape", {}, win);
+ await maybeShowPromise;
+ },
+ });
+}
+
+async function assertSection(sectionElement, expectedSection) {
+ info("Check the logo");
+ assertLogo(sectionElement, expectedSection.logoType);
+
+ info("Check the l10n");
+ assertL10N(sectionElement, expectedSection.l10n);
+
+ info("Check the visibility");
+ assertVisibility(sectionElement, expectedSection.visibility);
+
+ if (!gCanTabMoveFocus) {
+ Assert.ok(true, "Tab key can't move focus, skipping test for focus order");
+ return;
+ }
+
+ if (expectedSection.defaultFocusOrder) {
+ info("Check the default focus order");
+ assertFocusOrder(sectionElement, expectedSection.defaultFocusOrder);
+ }
+
+ if (expectedSection.acceptFocusOrder) {
+ info("Check the focus order after selecting accept option");
+ sectionElement.querySelector("#onboardingAccept").focus();
+ EventUtils.synthesizeKey("VK_SPACE", {}, sectionElement.ownerGlobal);
+ assertFocusOrder(sectionElement, expectedSection.acceptFocusOrder);
+ }
+
+ if (expectedSection.rejectFocusOrder) {
+ info("Check the focus order after selecting reject option");
+ sectionElement.querySelector("#onboardingReject").focus();
+ EventUtils.synthesizeKey("VK_SPACE", {}, sectionElement.ownerGlobal);
+ assertFocusOrder(sectionElement, expectedSection.rejectFocusOrder);
+ }
+}
+
+function assertLogo(sectionElement, expectedLogoType) {
+ let expectedLogoImage;
+ switch (expectedLogoType) {
+ case LOGO_TYPE.FIREFOX: {
+ expectedLogoImage = 'url("chrome://branding/content/about-logo.svg")';
+ break;
+ }
+ case LOGO_TYPE.MAGGLASS: {
+ expectedLogoImage =
+ 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass.svg")';
+ break;
+ }
+ case LOGO_TYPE.ANIMATION_MAGGLASS: {
+ const mediaQuery = sectionElement.ownerGlobal.matchMedia(
+ "(prefers-reduced-motion: no-preference)"
+ );
+ expectedLogoImage = mediaQuery.matches
+ ? 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass_animation.svg")'
+ : 'url("chrome://browser/content/urlbar/quicksuggestOnboarding_magglass.svg")';
+ break;
+ }
+ default: {
+ Assert.ok(false, `Unexpected image type ${expectedLogoType}`);
+ break;
+ }
+ }
+
+ const logo = sectionElement.querySelector(".logo");
+ Assert.ok(BrowserTestUtils.is_visible(logo));
+ const logoImage =
+ sectionElement.ownerGlobal.getComputedStyle(logo).backgroundImage;
+ Assert.equal(logoImage, expectedLogoImage);
+}
+
+function assertL10N(sectionElement, expectedL10N) {
+ for (const [id, l10n] of Object.entries(expectedL10N)) {
+ const element = sectionElement.querySelector("#" + id);
+ Assert.equal(element.getAttribute("data-l10n-id"), l10n);
+ }
+}
+
+function assertVisibility(sectionElement, expectedVisibility) {
+ for (const [selector, visibility] of Object.entries(expectedVisibility)) {
+ const element = sectionElement.querySelector(selector);
+ if (visibility) {
+ Assert.ok(BrowserTestUtils.is_visible(element));
+ } else {
+ if (!element) {
+ Assert.ok(true);
+ return;
+ }
+ Assert.ok(BrowserTestUtils.is_hidden(element));
+ }
+ }
+}
+
+function assertFocusOrder(sectionElement, expectedFocusOrder) {
+ const win = sectionElement.ownerGlobal;
+
+ // Check initial active element.
+ Assert.equal(win.document.activeElement.id, expectedFocusOrder[0]);
+
+ for (const next of expectedFocusOrder.slice(1)) {
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ Assert.equal(win.document.activeElement.id, next);
+ }
+}
+
+async function onboardingClose(variation) {
+ await doActionTest({
+ callback: async (win, userAction, maybeShowPromise) => {
+ info("Check the status of the close button");
+ const closeButton = win.document.getElementById("onboardingClose");
+ Assert.ok(BrowserTestUtils.is_visible(closeButton));
+ Assert.equal(closeButton.getAttribute("title"), "Close");
+
+ info("Commit the close button");
+ userAction(closeButton);
+
+ info("Waiting for maybeShowOnboardingDialog to finish");
+ await maybeShowPromise;
+ },
+ variation,
+ onboardingDialogVersion: JSON.stringify({
+ version: 1,
+ variation: variation.name.toLowerCase(),
+ }),
+ onboardingDialogChoice: "close_1",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "close_1",
+ },
+ ],
+ });
+}
+
+async function onboardingNext(variation) {
+ await doActionTest({
+ callback: async (win, userAction, maybeShowPromise) => {
+ info("Check the status of the next button");
+ const nextButton = win.document.getElementById("onboardingNext");
+ Assert.ok(BrowserTestUtils.is_visible(nextButton));
+
+ info("Commit the next button");
+ userAction(nextButton);
+
+ const introductionSection = win.document.getElementById(
+ "introduction-section"
+ );
+ const mainSection = win.document.getElementById("main-section");
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ BrowserTestUtils.is_hidden(introductionSection) &&
+ BrowserTestUtils.is_visible(mainSection),
+ "Wait for the transition"
+ );
+
+ info("Exit");
+ EventUtils.synthesizeKey("KEY_Escape", {}, win);
+
+ info("Waiting for maybeShowOnboardingDialog to finish");
+ await maybeShowPromise;
+ },
+ variation,
+ onboardingDialogVersion: JSON.stringify({
+ version: 1,
+ variation: variation.name.toLowerCase(),
+ }),
+ onboardingDialogChoice: "dismiss_2",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "dismiss_2",
+ },
+ ],
+ });
+}
+
+async function onboardingAccept(variation, skipIntroduction) {
+ await doActionTest({
+ callback: async (win, userAction, maybeShowPromise) => {
+ info("Check the status of the accept option and submit button");
+ const acceptOption = win.document.getElementById("onboardingAccept");
+ const submitButton = win.document.getElementById("onboardingSubmit");
+ Assert.ok(acceptOption);
+ Assert.ok(submitButton.disabled);
+
+ info("Select the accept option");
+ userAction(acceptOption);
+
+ info("Commit the submit button");
+ Assert.ok(!submitButton.disabled);
+ userAction(submitButton);
+
+ info("Waiting for maybeShowOnboardingDialog to finish");
+ await maybeShowPromise;
+ },
+ variation,
+ skipIntroduction,
+ onboardingDialogVersion: JSON.stringify({
+ version: 1,
+ variation: variation.name.toLowerCase(),
+ }),
+ onboardingDialogChoice: "accept_2",
+ expectedUserBranchPrefs: {
+ "quicksuggest.onboardingDialogVersion": JSON.stringify({ version: 1 }),
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "enabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "accept_2",
+ },
+ ],
+ });
+}
+
+async function onboardingReject(variation, skipIntroduction) {
+ await doActionTest({
+ callback: async (win, userAction, maybeShowPromise) => {
+ info("Check the status of the reject option and submit button");
+ const rejectOption = win.document.getElementById("onboardingReject");
+ const submitButton = win.document.getElementById("onboardingSubmit");
+ Assert.ok(rejectOption);
+ Assert.ok(submitButton.disabled);
+
+ info("Select the reject option");
+ userAction(rejectOption);
+
+ info("Commit the submit button");
+ Assert.ok(!submitButton.disabled);
+ userAction(submitButton);
+
+ info("Waiting for maybeShowOnboardingDialog to finish");
+ await maybeShowPromise;
+ },
+ variation,
+ skipIntroduction,
+ onboardingDialogVersion: JSON.stringify({
+ version: 1,
+ variation: variation.name.toLowerCase(),
+ }),
+ onboardingDialogChoice: "reject_2",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "reject_2",
+ },
+ ],
+ });
+}
+
+async function onboardingSkipLink(variation, skipIntroduction) {
+ await doActionTest({
+ callback: async (win, userAction, maybeShowPromise) => {
+ info("Check the status of the skip link");
+ const skipLink = win.document.getElementById("onboardingSkipLink");
+ Assert.ok(BrowserTestUtils.is_visible(skipLink));
+
+ info("Commit the skip link");
+ const tabCount = gBrowser.tabs.length;
+ userAction(skipLink);
+
+ info("Waiting for maybeShowOnboardingDialog to finish");
+ await maybeShowPromise;
+
+ info("Check the current tab status");
+ Assert.equal(
+ gBrowser.currentURI.spec,
+ "about:blank",
+ "Nothing loaded in the current tab"
+ );
+ Assert.equal(gBrowser.tabs.length, tabCount, "No news tabs were opened");
+ },
+ variation,
+ skipIntroduction,
+ onboardingDialogVersion: JSON.stringify({
+ version: 1,
+ variation: variation.name.toLowerCase(),
+ }),
+ onboardingDialogChoice: "not_now_2",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "not_now_2",
+ },
+ ],
+ });
+}
+
+async function onboardingLearnMore(variation, skipIntroduction) {
+ await doLearnMoreTest(
+ variation,
+ skipIntroduction,
+ "onboardingLearnMore",
+ "learn_more_2"
+ );
+}
+
+async function onboardingLearnMoreOnIntroduction(variation, skipIntroduction) {
+ await doLearnMoreTest(
+ variation,
+ skipIntroduction,
+ "onboardingLearnMoreOnIntroduction",
+ "learn_more_1"
+ );
+}
+
+async function doLearnMoreTest(variation, skipIntroduction, target, telemetry) {
+ await doActionTest({
+ callback: async (win, userAction, maybeShowPromise) => {
+ info("Check the status of the learn more link");
+ const learnMoreLink = win.document.getElementById(target);
+ Assert.ok(BrowserTestUtils.is_visible(learnMoreLink));
+
+ info("Commit the learn more link");
+ const loadPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ QuickSuggest.HELP_URL
+ ).then(tab => {
+ info("Saw new tab");
+ return tab;
+ });
+ userAction(learnMoreLink);
+
+ info("Waiting for maybeShowOnboardingDialog to finish");
+ await maybeShowPromise;
+
+ info("Waiting for new tab");
+ let tab = await loadPromise;
+
+ info("Check the current tab status");
+ Assert.equal(gBrowser.selectedTab, tab, "Current tab is the new tab");
+ Assert.equal(
+ gBrowser.currentURI.spec,
+ QuickSuggest.HELP_URL,
+ "Current tab is the support page"
+ );
+ BrowserTestUtils.removeTab(tab);
+ },
+ variation,
+ skipIntroduction,
+ onboardingDialogChoice: telemetry,
+ onboardingDialogVersion: JSON.stringify({
+ version: 1,
+ variation: variation.name.toLowerCase(),
+ }),
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: telemetry,
+ },
+ ],
+ });
+}
+
+async function doActionTest({
+ variation,
+ skipIntroduction,
+ callback,
+ onboardingDialogVersion,
+ onboardingDialogChoice,
+ expectedUserBranchPrefs,
+ telemetryEvents,
+}) {
+ const userClick = target => {
+ info("Click on the target");
+ target.click();
+ };
+ const userEnter = target => {
+ target.focus();
+ if (target.type === "radio") {
+ info("Space on the target");
+ EventUtils.synthesizeKey("VK_SPACE", {}, target.ownerGlobal);
+ } else {
+ info("Enter on the target");
+ EventUtils.synthesizeKey("KEY_Enter", {}, target.ownerGlobal);
+ }
+ };
+
+ for (const userAction of [userClick, userEnter]) {
+ UrlbarPrefs.clear("quicksuggest.shouldShowOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.showedOnboardingDialog");
+ UrlbarPrefs.clear("quicksuggest.seenRestarts", 0);
+
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides: {
+ quickSuggestScenario: "online",
+ quickSuggestOnboardingDialogVariation: variation.name,
+ },
+ callback: async () => {
+ await doDialogTest({
+ callback: async () => {
+ info("Calling showOnboardingDialog");
+ const { win, maybeShowPromise } = await showOnboardingDialog({
+ skipIntroduction,
+ });
+
+ await callback(win, userAction, maybeShowPromise);
+ },
+ onboardingDialogVersion,
+ onboardingDialogChoice,
+ expectedUserBranchPrefs,
+ telemetryEvents,
+ });
+ },
+ });
+ }
+}
+
+async function doDialogTest({
+ callback,
+ onboardingDialogVersion,
+ onboardingDialogChoice,
+ telemetryEvents,
+ expectedUserBranchPrefs,
+}) {
+ setDialogPrereqPrefs();
+
+ // Set initial prefs on the default branch.
+ let initialDefaultBranch = OFFLINE_DEFAULT_PREFS;
+ let originalDefaultBranch = {};
+ for (let [name, value] of Object.entries(initialDefaultBranch)) {
+ originalDefaultBranch = gDefaultBranch.getBoolPref(name);
+ gDefaultBranch.setBoolPref(name, value);
+ gUserBranch.clearUserPref(name);
+ }
+
+ // Setting the prefs just now triggered telemetry events, so clear them
+ // before calling the callback.
+ Services.telemetry.clearEvents();
+
+ // Call the callback, which should trigger the dialog and interact with it.
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ await callback();
+ });
+
+ // Now check all pref values on the default and user branches.
+ for (let [name, value] of Object.entries(initialDefaultBranch)) {
+ Assert.equal(
+ gDefaultBranch.getBoolPref(name),
+ value,
+ "Default-branch value for pref did not change after modal: " + name
+ );
+
+ let effectiveValue;
+ if (name in expectedUserBranchPrefs) {
+ effectiveValue = expectedUserBranchPrefs[name];
+ Assert.equal(
+ gUserBranch.getBoolPref(name),
+ effectiveValue,
+ "User-branch value for pref has expected value: " + name
+ );
+ } else {
+ effectiveValue = value;
+ Assert.ok(
+ !gUserBranch.prefHasUserValue(name),
+ "User-branch value for pref does not exist: " + name
+ );
+ }
+
+ // For good measure, check the value returned by UrlbarPrefs.
+ Assert.equal(
+ UrlbarPrefs.get(name),
+ effectiveValue,
+ "Effective value for pref is correct: " + name
+ );
+ }
+
+ Assert.equal(
+ UrlbarPrefs.get("quicksuggest.onboardingDialogVersion"),
+ onboardingDialogVersion,
+ "onboardingDialogVersion"
+ );
+ Assert.equal(
+ UrlbarPrefs.get("quicksuggest.onboardingDialogChoice"),
+ onboardingDialogChoice,
+ "onboardingDialogChoice"
+ );
+ Assert.equal(
+ TelemetryEnvironment.currentEnvironment.settings.userPrefs[
+ "browser.urlbar.quicksuggest.onboardingDialogChoice"
+ ],
+ onboardingDialogChoice,
+ "onboardingDialogChoice is correct in TelemetryEnvironment"
+ );
+
+ QuickSuggestTestUtils.assertEvents(telemetryEvents);
+
+ Assert.ok(
+ UrlbarPrefs.get("quicksuggest.showedOnboardingDialog"),
+ "quicksuggest.showedOnboardingDialog is true after showing dialog"
+ );
+
+ // Clean up.
+ for (let [name, value] of Object.entries(originalDefaultBranch)) {
+ gDefaultBranch.setBoolPref(name, value);
+ }
+ for (let name of Object.keys(expectedUserBranchPrefs)) {
+ gUserBranch.clearUserPref(name);
+ }
+}
+
+/**
+ * Show onbaording dialog.
+ *
+ * @param {object} [options]
+ * The object options.
+ * @param {boolean} [options.skipIntroduction]
+ * If true, return dialog with skipping the introduction section.
+ * @returns {{ window, maybeShowPromise: Promise }}
+ * win: window object of the dialog.
+ * maybeShowPromise: Promise of QuickSuggest.maybeShowOnboardingDialog().
+ */
+async function showOnboardingDialog({ skipIntroduction } = {}) {
+ const dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ QuickSuggest.ONBOARDING_URI,
+ { isSubDialog: true }
+ );
+
+ const maybeShowPromise = QuickSuggest.maybeShowOnboardingDialog();
+
+ const win = await dialogPromise;
+ if (win.document.readyState != "complete") {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ }
+
+ // Wait until all listers on onboarding dialog are ready.
+ await window._quicksuggestOnboardingReady;
+
+ if (!skipIntroduction) {
+ return { win, maybeShowPromise };
+ }
+
+ // Trigger the transition by pressing Enter on the Next button.
+ EventUtils.synthesizeKey("KEY_Enter");
+
+ const introductionSection = win.document.getElementById(
+ "introduction-section"
+ );
+ const mainSection = win.document.getElementById("main-section");
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ BrowserTestUtils.is_hidden(introductionSection) &&
+ BrowserTestUtils.is_visible(mainSection)
+ );
+
+ return { win, maybeShowPromise };
+}
+
+/**
+ * Sets all the required prefs for showing the onboarding dialog except for the
+ * prefs that are set when the dialog is accepted.
+ */
+function setDialogPrereqPrefs() {
+ UrlbarPrefs.set("quicksuggest.shouldShowOnboardingDialog", true);
+ UrlbarPrefs.set("quicksuggest.showedOnboardingDialog", false);
+}
+
+/**
+ * This is a real hacky way of determining whether the tab key can move focus.
+ * Windows and Linux both support it but macOS does not unless full keyboard
+ * access is enabled, so practically this is only useful on macOS. Gecko seems
+ * to know whether full keyboard access is enabled because it affects focus in
+ * Firefox and some code in nsXULElement.cpp and other places mention it, but
+ * there doesn't seem to be a way to access that information from JS. There is
+ * `Services.focus.elementIsFocusable`, but it returns true regardless of
+ * whether full access is enabled.
+ *
+ * So what we do here is open the dialog and synthesize a tab key. If the focus
+ * doesn't change, then we assume moving the focus via the tab key is not
+ * supported.
+ *
+ * Why not just always skip the focus tasks on Mac? Because individual
+ * developers (like the one writing this comment) may be running macOS with full
+ * keyboard access enabled and want to actually run the tasks on their machines.
+ *
+ * @returns {boolean}
+ */
+async function canTabMoveFocus() {
+ if (AppConstants.platform != "macosx") {
+ return true;
+ }
+
+ let canMove = false;
+ await doDialogTest({
+ callback: async () => {
+ const { win, maybeShowPromise } = await showOnboardingDialog({
+ skipIntroduction: true,
+ });
+
+ let doc = win.document;
+ doc.getElementById("onboardingAccept").focus();
+ EventUtils.synthesizeKey("KEY_Tab");
+
+ // Whether or not the focus can move to the link.
+ canMove = doc.activeElement.id === "onboardingLearnMore";
+
+ EventUtils.synthesizeKey("KEY_Escape");
+ await maybeShowPromise;
+ },
+ onboardingDialogVersion: JSON.stringify({ version: 1 }),
+ onboardingDialogChoice: "dismiss_2",
+ expectedUserBranchPrefs: {
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ telemetryEvents: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: "disabled",
+ },
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "opt_in_dialog",
+ object: "dismiss_2",
+ },
+ ],
+ });
+
+ return canMove;
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js
new file mode 100644
index 0000000000..ece6239953
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests primary telemetry for dynamic Wikipedia suggestions.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+});
+
+const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;
+
+const MERINO_SUGGESTION = {
+ block_id: 1,
+ url: "https://example.com/dynamic-wikipedia",
+ title: "Dynamic Wikipedia suggestion",
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "dynamic-wikipedia",
+ provider: "wikipedia",
+ iab_category: "5 - Education",
+};
+
+const suggestion_type = "dynamic-wikipedia";
+const match_type = "firefox-suggest";
+const index = 1;
+const position = index + 1;
+
+add_setup(async function () {
+ await setUpTelemetryTest({
+ merinoSuggestions: [MERINO_SUGGESTION],
+ });
+});
+
+add_task(async function () {
+ await doTelemetryTest({
+ index,
+ suggestion: MERINO_SUGGESTION,
+ // impression-only
+ impressionOnly: {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ selectables: {
+ // click
+ "urlbarView-row-inner": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position,
+ [TELEMETRY_SCALARS.CLICK_DYNAMIC_WIKIPEDIA]: position,
+ "urlbar.picked.dynamic_wikipedia": index.toString(),
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ // block
+ "urlbarView-button-block": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position,
+ [TELEMETRY_SCALARS.BLOCK_DYNAMIC_WIKIPEDIA]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ // help
+ "urlbarView-button-help": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_DYNAMIC_WIKIPEDIA]: position,
+ [TELEMETRY_SCALARS.HELP_DYNAMIC_WIKIPEDIA]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "help",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ },
+ });
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js
new file mode 100644
index 0000000000..c52d22a886
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js
@@ -0,0 +1,477 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests abandonment and edge cases related to impressions.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+ UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;
+
+const REMOTE_SETTINGS_RESULTS = [
+ {
+ id: 1,
+ url: "https://example.com/sponsored",
+ title: "Sponsored suggestion",
+ keywords: ["sponsored"],
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "testadvertiser",
+ },
+ {
+ id: 2,
+ url: "https://example.com/nonsponsored",
+ title: "Non-sponsored suggestion",
+ keywords: ["nonsponsored"],
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "testadvertiser",
+ iab_category: "5 - Education",
+ },
+];
+
+const SPONSORED_RESULT = REMOTE_SETTINGS_RESULTS[0];
+
+// Spy for the custom impression/click sender
+let spy;
+
+add_setup(async function () {
+ ({ spy } = QuickSuggestTestUtils.createTelemetryPingSpy());
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+
+ // Add a mock engine so we don't hit the network.
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsResults: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ });
+});
+
+// Makes sure impression telemetry is not recorded when the urlbar engagement is
+// abandoned.
+add_task(async function abandonment() {
+ Services.telemetry.clearEvents();
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "sponsored",
+ fireInputEvent: true,
+ });
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ url: SPONSORED_RESULT.url,
+ });
+ await UrlbarTestUtils.promisePopupClose(window, () => {
+ gURLBar.blur();
+ });
+ QuickSuggestTestUtils.assertScalars({});
+ QuickSuggestTestUtils.assertEvents([]);
+ QuickSuggestTestUtils.assertPings(spy, []);
+});
+
+// Makes sure impression telemetry is not recorded when a quick suggest result
+// is not present.
+add_task(async function noQuickSuggestResult() {
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ Services.telemetry.clearEvents();
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "noImpression_noQuickSuggestResult",
+ fireInputEvent: true,
+ });
+ await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
+ await UrlbarTestUtils.promisePopupClose(window, () => {
+ EventUtils.synthesizeKey("KEY_Enter");
+ });
+ QuickSuggestTestUtils.assertScalars({});
+ QuickSuggestTestUtils.assertEvents([]);
+ QuickSuggestTestUtils.assertPings(spy, []);
+ });
+ await PlacesUtils.history.clear();
+});
+
+// When a quick suggest result is added to the view but hidden during the view
+// update, impression telemetry should not be recorded for it.
+add_task(async function hiddenRow() {
+ Services.telemetry.clearEvents();
+
+ // Increase the timeout of the remove-stale-rows timer so that it doesn't
+ // interfere with this task.
+ let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout;
+ UrlbarView.removeStaleRowsTimeout = 30000;
+ registerCleanupFunction(() => {
+ UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout;
+ });
+
+ // Set up a test provider that doesn't add any results until we resolve its
+ // `finishQueryPromise`. For the first search below, it will add many search
+ // suggestions.
+ let maxCount = UrlbarPrefs.get("maxRichResults");
+ let results = [];
+ for (let i = 0; i < maxCount; i++) {
+ results.push(
+ new UrlbarResult(
+ UrlbarUtils.RESULT_TYPE.SEARCH,
+ UrlbarUtils.RESULT_SOURCE.SEARCH,
+ {
+ engine: "Example",
+ suggestion: "suggestion " + i,
+ lowerCaseSuggestion: "suggestion " + i,
+ query: "test",
+ }
+ )
+ );
+ }
+ let provider = new DelayingTestProvider({ results });
+ UrlbarProvidersManager.registerProvider(provider);
+
+ // Open a new tab since we'll load a page below.
+ let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser });
+
+ // Do a normal search and allow the test provider to finish.
+ provider.finishQueryPromise = Promise.resolve();
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "test",
+ fireInputEvent: true,
+ });
+
+ // Sanity check the rows. After the heuristic, the remaining rows should be
+ // the search results added by the test provider.
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ maxCount,
+ "Row count after first search"
+ );
+ for (let i = 1; i < maxCount; i++) {
+ let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.equal(
+ result.type,
+ UrlbarUtils.RESULT_TYPE.SEARCH,
+ "Expected result type at index " + i
+ );
+ Assert.equal(
+ result.source,
+ UrlbarUtils.RESULT_SOURCE.SEARCH,
+ "Expected result source at index " + i
+ );
+ }
+
+ // Now set up a second search that triggers a quick suggest result. Add a
+ // mutation listener to the view so we can tell when the quick suggest row is
+ // added.
+ let mutationPromise = new Promise(resolve => {
+ let observer = new MutationObserver(mutations => {
+ let rows = UrlbarTestUtils.getResultsContainer(window).children;
+ for (let row of rows) {
+ if (row.result.providerName == "UrlbarProviderQuickSuggest") {
+ observer.disconnect();
+ resolve(row);
+ return;
+ }
+ }
+ });
+ observer.observe(UrlbarTestUtils.getResultsContainer(window), {
+ childList: true,
+ });
+ });
+
+ // Set the test provider's `finishQueryPromise` to a promise that doesn't
+ // resolve. That will prevent the search from completing, which will prevent
+ // the view from removing stale rows and showing the quick suggest row.
+ let resolveQuery;
+ provider.finishQueryPromise = new Promise(
+ resolve => (resolveQuery = resolve)
+ );
+
+ // Start the second search but don't wait for it to finish.
+ gURLBar.focus();
+ let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: REMOTE_SETTINGS_RESULTS[0].keywords[0],
+ fireInputEvent: true,
+ });
+
+ // Wait for the quick suggest row to be added to the view. It should be hidden
+ // because (a) quick suggest results have a `suggestedIndex`, and rows with
+ // suggested indexes can't replace rows without suggested indexes, and (b) the
+ // view already contains the maximum number of rows due to the first search.
+ // It should remain hidden until the search completes or the remove-stale-rows
+ // timer fires. Next, we'll hit enter, which will cancel the search and close
+ // the view, so the row should never appear.
+ let quickSuggestRow = await mutationPromise;
+ Assert.ok(
+ BrowserTestUtils.is_hidden(quickSuggestRow),
+ "Quick suggest row is hidden"
+ );
+
+ // Hit enter to pick the heuristic search result. This will cancel the search
+ // and notify the quick suggest provider that an engagement occurred.
+ let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await UrlbarTestUtils.promisePopupClose(window, () => {
+ EventUtils.synthesizeKey("KEY_Enter");
+ });
+ await loadPromise;
+
+ // Resolve the test provider's promise finally.
+ resolveQuery();
+ await queryPromise;
+
+ // The quick suggest provider added a result but it wasn't visible in the
+ // view. No impression telemetry should be recorded for it.
+ QuickSuggestTestUtils.assertScalars({});
+ QuickSuggestTestUtils.assertEvents([]);
+ QuickSuggestTestUtils.assertPings(spy, []);
+
+ BrowserTestUtils.removeTab(tab);
+ UrlbarProvidersManager.unregisterProvider(provider);
+ UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout;
+});
+
+// When a quick suggest result has not been added to the view, impression
+// telemetry should not be recorded for it even if it's the result most recently
+// returned by the provider.
+add_task(async function notAddedToView() {
+ Services.telemetry.clearEvents();
+
+ // Open a new tab since we'll load a page.
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ // Do an initial search that doesn't match any suggestions to make sure
+ // there aren't any quick suggest results in the view to start.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "this doesn't match anything",
+ fireInputEvent: true,
+ });
+ await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ // Now do a search for a suggestion and hit enter after the provider adds it
+ // but before it appears in the view.
+ await doEngagementWithoutAddingResultToView(
+ REMOTE_SETTINGS_RESULTS[0].keywords[0]
+ );
+
+ // The quick suggest provider added a result but it wasn't visible in the
+ // view, and no other quick suggest results were visible in the view. No
+ // impression telemetry should be recorded.
+ QuickSuggestTestUtils.assertScalars({});
+ QuickSuggestTestUtils.assertEvents([]);
+ QuickSuggestTestUtils.assertPings(spy, []);
+ });
+});
+
+// When a quick suggest result is visible in the view, impression telemetry
+// should be recorded for it even if it's not the result most recently returned
+// by the provider.
+add_task(async function previousResultStillVisible() {
+ Services.telemetry.clearEvents();
+
+ // Open a new tab since we'll load a page.
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ // Do a search for the first suggestion.
+ let firstSuggestion = REMOTE_SETTINGS_RESULTS[0];
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: firstSuggestion.keywords[0],
+ fireInputEvent: true,
+ });
+
+ let index = 1;
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ index,
+ url: firstSuggestion.url,
+ });
+
+ // Without closing the view, do a second search for the second suggestion
+ // and hit enter after the provider adds it but before it appears in the
+ // view.
+ await doEngagementWithoutAddingResultToView(
+ REMOTE_SETTINGS_RESULTS[1].keywords[0],
+ index
+ );
+
+ // An impression for the first suggestion should be recorded since it's
+ // still visible in the view, not the second suggestion.
+ QuickSuggestTestUtils.assertScalars({
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: index + 1,
+ });
+ QuickSuggestTestUtils.assertEvents([
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ match_type: "firefox-suggest",
+ position: String(index + 1),
+ suggestion_type: "sponsored",
+ },
+ },
+ ]);
+ QuickSuggestTestUtils.assertPings(spy, [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ improve_suggest_experience_checked: false,
+ block_id: firstSuggestion.id,
+ is_clicked: false,
+ match_type: "firefox-suggest",
+ position: index + 1,
+ },
+ },
+ ]);
+ });
+});
+
+/**
+ * Does a search that causes the quick suggest provider to return a result
+ * without adding it to the view and then hits enter to load a SERP and create
+ * an engagement.
+ *
+ * @param {string} searchString
+ * The search string.
+ * @param {number} previousResultIndex
+ * If the view is already open and showing a quick suggest result, pass its
+ * index here. Otherwise pass -1.
+ */
+async function doEngagementWithoutAddingResultToView(
+ searchString,
+ previousResultIndex = -1
+) {
+ // Set the timeout of the chunk timer to a really high value so that it will
+ // not fire. The view updates when the timer fires, which we specifically want
+ // to avoid here.
+ let originalChunkDelayMs = UrlbarProvidersManager._chunkResultsDelayMs;
+ UrlbarProvidersManager._chunkResultsDelayMs = 30000;
+ registerCleanupFunction(() => {
+ UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs;
+ });
+
+ // Stub `UrlbarProviderQuickSuggest.getPriority()` to return Infinity.
+ let sandbox = sinon.createSandbox();
+ let getPriorityStub = sandbox.stub(UrlbarProviderQuickSuggest, "getPriority");
+ getPriorityStub.returns(Infinity);
+
+ // Spy on `UrlbarProviderQuickSuggest.onEngagement()`.
+ let onEngagementSpy = sandbox.spy(UrlbarProviderQuickSuggest, "onEngagement");
+
+ let sandboxCleanup = () => {
+ getPriorityStub?.restore();
+ getPriorityStub = null;
+ sandbox?.restore();
+ sandbox = null;
+ };
+ registerCleanupFunction(sandboxCleanup);
+
+ // In addition to setting the chunk timeout to a large value above, in order
+ // to prevent the view from updating there also needs to be a heuristic
+ // provider that takes a long time to add results. Set one up that doesn't add
+ // any results until we resolve its `finishQueryPromise`. Set its priority to
+ // Infinity too so that only it and the quick suggest provider will be active.
+ let provider = new DelayingTestProvider({
+ results: [],
+ priority: Infinity,
+ type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC,
+ });
+ UrlbarProvidersManager.registerProvider(provider);
+
+ let resolveQuery;
+ provider.finishQueryPromise = new Promise(r => (resolveQuery = r));
+
+ // Add a query listener so we can grab the query context.
+ let context;
+ let queryListener = {
+ onQueryStarted: c => (context = c),
+ };
+ gURLBar.controller.addQueryListener(queryListener);
+
+ // Do a search but don't wait for it to finish.
+ gURLBar.focus();
+ UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchString,
+ fireInputEvent: true,
+ });
+
+ // Wait for the quick suggest provider to add its result to `context.unsortedResults`.
+ let result = await TestUtils.waitForCondition(() => {
+ let query = UrlbarProvidersManager.queries.get(context);
+ return query?.unsortedResults.find(
+ r => r.providerName == "UrlbarProviderQuickSuggest"
+ );
+ }, "Waiting for quick suggest result to be added to context.unsortedResults");
+
+ gURLBar.controller.removeQueryListener(queryListener);
+
+ // The view should not have updated, so the result's `rowIndex` should still
+ // have its initial value of -1.
+ Assert.equal(result.rowIndex, -1, "result.rowIndex is still -1");
+
+ // If there's a result from the previous query, assert it's still in the
+ // view. Otherwise assume that the view should be closed. These are mostly
+ // sanity checks because they should only fail if the telemetry assertions
+ // below also fail.
+ if (previousResultIndex >= 0) {
+ let rows = gURLBar.view.panel.querySelector(".urlbarView-results");
+ Assert.equal(
+ rows.children[previousResultIndex].result.providerName,
+ "UrlbarProviderQuickSuggest",
+ "Result already in view is a quick suggest"
+ );
+ } else {
+ Assert.ok(!gURLBar.view.isOpen, "View is closed");
+ }
+
+ // Hit enter to load a SERP for the search string. This should notify the
+ // quick suggest provider that an engagement occurred.
+ let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await UrlbarTestUtils.promisePopupClose(window, () => {
+ EventUtils.synthesizeKey("KEY_Enter");
+ });
+ await loadPromise;
+
+ let engagementCalls = onEngagementSpy.getCalls().filter(call => {
+ let state = call.args[1];
+ return state == "engagement";
+ });
+ Assert.equal(engagementCalls.length, 1, "One engagement occurred");
+
+ // Clean up.
+ resolveQuery();
+ UrlbarProvidersManager.unregisterProvider(provider);
+ UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs;
+ sandboxCleanup();
+}
+
+/**
+ * A test provider that doesn't finish `startQuery()` until `finishQueryPromise`
+ * is resolved.
+ */
+class DelayingTestProvider extends UrlbarTestUtils.TestProvider {
+ finishQueryPromise = null;
+ async startQuery(context, addCallback) {
+ for (let result of this._results) {
+ addCallback(this, result);
+ }
+ await this.finishQueryPromise;
+ }
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js
new file mode 100644
index 0000000000..defb9bb76d
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js
@@ -0,0 +1,353 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests primary telemetry for navigational suggestions, a.k.a.
+ * navigational top picks.
+ */
+
+"use strict";
+
+const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;
+
+const MERINO_SUGGESTION = {
+ title: "Navigational suggestion",
+ url: "https://example.com/navigational-suggestion",
+ provider: "top_picks",
+ is_sponsored: false,
+ score: 0.25,
+ block_id: 0,
+ is_top_pick: true,
+};
+
+const suggestion_type = "navigational";
+const index = 1;
+const position = index + 1;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // `bestMatch.enabled` must be set to show nav suggestions with the best
+ // match UI treatment.
+ ["browser.urlbar.bestMatch.enabled", true],
+ // Disable tab-to-search since like best match it's also shown with
+ // `suggestedIndex` = 1.
+ ["browser.urlbar.suggest.engines", false],
+ ],
+ });
+
+ await setUpTelemetryTest({
+ merinoSuggestions: [MERINO_SUGGESTION],
+ });
+});
+
+// Clicks the heuristic when a nav suggestion is not matched
+add_task(async function notMatched_clickHeuristic() {
+ await doTest({
+ suggestion: null,
+ shouldBeShown: false,
+ pickRowIndex: 0,
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED]: "search_engine",
+ [TELEMETRY_SCALARS.CLICK_NAV_NOTMATCHED]: "search_engine",
+ },
+ events: [],
+ });
+});
+
+// Clicks a non-heuristic row when a nav suggestion is not matched
+add_task(async function notMatched_clickOther() {
+ await PlacesTestUtils.addVisits("http://mochi.test:8888/example");
+ await doTest({
+ suggestion: null,
+ shouldBeShown: false,
+ pickRowIndex: 1,
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NAV_NOTMATCHED]: "search_engine",
+ },
+ events: [],
+ });
+});
+
+// Clicks the heuristic when a nav suggestion is shown
+add_task(async function shown_clickHeuristic() {
+ await doTest({
+ suggestion: MERINO_SUGGESTION,
+ shouldBeShown: true,
+ pickRowIndex: 0,
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine",
+ [TELEMETRY_SCALARS.CLICK_NAV_SHOWN_HEURISTIC]: "search_engine",
+ },
+ events: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type: "best-match",
+ position: position.toString(),
+ source: "merino",
+ },
+ },
+ ],
+ });
+});
+
+// Clicks the nav suggestion
+add_task(async function shown_clickNavSuggestion() {
+ await doTest({
+ suggestion: MERINO_SUGGESTION,
+ shouldBeShown: true,
+ pickRowIndex: index,
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine",
+ [TELEMETRY_SCALARS.CLICK_NAV_SHOWN_NAV]: "search_engine",
+ "urlbar.picked.navigational": "1",
+ },
+ events: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type: "best-match",
+ position: position.toString(),
+ source: "merino",
+ },
+ },
+ ],
+ });
+});
+
+// Clicks a non-heuristic non-nav-suggestion row when the nav suggestion is
+// shown
+add_task(async function shown_clickOther() {
+ await PlacesTestUtils.addVisits("http://mochi.test:8888/example");
+ await doTest({
+ suggestion: MERINO_SUGGESTION,
+ shouldBeShown: true,
+ pickRowIndex: 2,
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NAV_SHOWN]: "search_engine",
+ },
+ events: [
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type: "best-match",
+ position: position.toString(),
+ source: "merino",
+ },
+ },
+ ],
+ });
+});
+
+// Clicks the heuristic when it dupes the nav suggestion
+add_task(async function duped_clickHeuristic() {
+ // Add enough visits to example.com so it autofills.
+ for (let i = 0; i < 5; i++) {
+ await PlacesTestUtils.addVisits("https://example.com/");
+ }
+
+ // Set the nav suggestion's URL to the same URL, example.com.
+ let suggestion = {
+ ...MERINO_SUGGESTION,
+ url: "https://example.com/",
+ };
+
+ await doTest({
+ suggestion,
+ shouldBeShown: false,
+ pickRowIndex: 0,
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED]: "autofill_origin",
+ [TELEMETRY_SCALARS.CLICK_NAV_SUPERCEDED]: "autofill_origin",
+ },
+ events: [],
+ });
+});
+
+// Clicks a non-heuristic row when the heuristic dupes the nav suggestion
+add_task(async function duped_clickOther() {
+ // Add enough visits to example.com so it autofills.
+ for (let i = 0; i < 5; i++) {
+ await PlacesTestUtils.addVisits("https://example.com/");
+ }
+
+ // Set the nav suggestion's URL to the same URL, example.com.
+ let suggestion = {
+ ...MERINO_SUGGESTION,
+ url: "https://example.com/",
+ };
+
+ // Add a visit to another URL so it appears in the search below.
+ await PlacesTestUtils.addVisits("https://example.com/some-other-url");
+
+ await doTest({
+ suggestion,
+ shouldBeShown: false,
+ pickRowIndex: 1,
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NAV_SUPERCEDED]: "autofill_origin",
+ },
+ events: [],
+ });
+});
+
+// Telemetry specific to nav suggestions should not be recorded when the
+// `recordNavigationalSuggestionTelemetry` Nimbus variable is false.
+add_task(async function recordNavigationalSuggestionTelemetry_false() {
+ await doTest({
+ valueOverrides: {
+ recordNavigationalSuggestionTelemetry: false,
+ },
+ suggestion: MERINO_SUGGESTION,
+ shouldBeShown: true,
+ pickRowIndex: index,
+ scalars: {},
+ events: [
+ // The legacy engagement event should still be recorded as it is for all
+ // quick suggest results.
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type: "best-match",
+ position: position.toString(),
+ source: "merino",
+ },
+ },
+ ],
+ });
+});
+
+// Telemetry specific to nav suggestions should not be recorded when the
+// `recordNavigationalSuggestionTelemetry` Nimbus variable is left out.
+add_task(async function recordNavigationalSuggestionTelemetry_undefined() {
+ await doTest({
+ valueOverrides: {},
+ suggestion: MERINO_SUGGESTION,
+ shouldBeShown: true,
+ pickRowIndex: index,
+ scalars: {},
+ events: [
+ // The legacy engagement event should still be recorded as it is for all
+ // quick suggest results.
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type: "best-match",
+ position: position.toString(),
+ source: "merino",
+ },
+ },
+ ],
+ });
+});
+
+/**
+ * Does the following:
+ *
+ * 1. Sets up a Merino nav suggestion
+ * 2. Enrolls in a Nimbus experiment with the specified variables
+ * 3. Does a search
+ * 4. Makes sure the nav suggestion is or isn't shown as expected
+ * 5. Clicks a specified row
+ * 6. Makes sure the expected telemetry is recorded
+ *
+ * @param {object} options
+ * Options object
+ * @param {object} options.suggestion
+ * The nav suggestion or null if Merino shouldn't serve one.
+ * @param {boolean} options.shouldBeShown
+ * Whether the nav suggestion is expected to be shown.
+ * @param {number} options.pickRowIndex
+ * The index of the row to pick.
+ * @param {object} options.scalars
+ * An object that specifies the nav suggest keyed scalars that are expected to
+ * be recorded.
+ * @param {Array} options.events
+ * An object that specifies the legacy engagement events that are expected to
+ * be recorded.
+ * @param {object} options.valueOverrides
+ * The Nimbus variables to use.
+ */
+async function doTest({
+ suggestion,
+ shouldBeShown,
+ pickRowIndex,
+ scalars,
+ events,
+ valueOverrides = {
+ recordNavigationalSuggestionTelemetry: true,
+ },
+}) {
+ MerinoTestUtils.server.response.body.suggestions = suggestion
+ ? [suggestion]
+ : [];
+
+ Services.telemetry.clearEvents();
+ let { spy, spyCleanup } = QuickSuggestTestUtils.createTelemetryPingSpy();
+
+ await QuickSuggestTestUtils.withExperiment({
+ valueOverrides,
+ callback: async () => {
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ gURLBar.focus();
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "example",
+ fireInputEvent: true,
+ });
+
+ if (shouldBeShown) {
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ index,
+ url: suggestion.url,
+ isBestMatch: true,
+ isSponsored: false,
+ });
+ } else {
+ await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
+ }
+
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser
+ );
+ if (pickRowIndex > 0) {
+ info("Arrowing down to row index " + pickRowIndex);
+ EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: pickRowIndex });
+ }
+ info("Pressing Enter and waiting for page load");
+ EventUtils.synthesizeKey("KEY_Enter");
+ await loadPromise;
+ });
+ },
+ });
+
+ info("Checking scalars");
+ QuickSuggestTestUtils.assertScalars(scalars);
+
+ info("Checking events");
+ QuickSuggestTestUtils.assertEvents(events);
+
+ info("Checking pings");
+ QuickSuggestTestUtils.assertPings(spy, []);
+
+ await spyCleanup();
+ await PlacesUtils.history.clear();
+ MerinoTestUtils.server.response.body.suggestions = [MERINO_SUGGESTION];
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js
new file mode 100644
index 0000000000..c7ddcdd2ce
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js
@@ -0,0 +1,368 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests primary telemetry for nonsponsored suggestions.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+});
+
+const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;
+
+const REMOTE_SETTINGS_RESULT = {
+ id: 1,
+ url: "https://example.com/nonsponsored",
+ title: "Non-sponsored suggestion",
+ keywords: ["nonsponsored"],
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "testadvertiser",
+ iab_category: "5 - Education",
+};
+
+const suggestion_type = "nonsponsored";
+const index = 1;
+const position = index + 1;
+
+add_setup(async function () {
+ await setUpTelemetryTest({
+ remoteSettingsResults: [
+ {
+ type: "data",
+ attachment: [REMOTE_SETTINGS_RESULT],
+ },
+ ],
+ });
+});
+
+// nonsponsored
+add_task(async function nonsponsored() {
+ let match_type = "firefox-suggest";
+
+ // Make sure `improve_suggest_experience_checked` is recorded correctly
+ // depending on the value of the related pref.
+ for (let improve_suggest_experience_checked of [false, true]) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.urlbar.quicksuggest.dataCollection.enabled",
+ improve_suggest_experience_checked,
+ ],
+ ],
+ });
+ await doTelemetryTest({
+ index,
+ suggestion: REMOTE_SETTINGS_RESULT,
+ // impression-only
+ impressionOnly: {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ ping: {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ },
+ selectables: {
+ // click
+ "urlbarView-row-inner": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.CLICK_NONSPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: true,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ // block
+ "urlbarView-button-block": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.BLOCK_NONSPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ iab_category: REMOTE_SETTINGS_RESULT.iab_category,
+ },
+ },
+ ],
+ },
+ // help
+ "urlbarView-button-help": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.HELP_NONSPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "help",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ },
+ });
+ await SpecialPowers.popPrefEnv();
+ }
+});
+
+// nonsponsored best match
+add_task(async function nonsponsoredBestMatch() {
+ let match_type = "best-match";
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.bestMatch.enabled", true]],
+ });
+ await QuickSuggestTestUtils.setConfig(
+ QuickSuggestTestUtils.BEST_MATCH_CONFIG
+ );
+ await doTelemetryTest({
+ index,
+ suggestion: REMOTE_SETTINGS_RESULT,
+ // impression-only
+ impressionOnly: {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ ping: {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ },
+ selectables: {
+ // click
+ "urlbarView-row-inner": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position,
+ [TELEMETRY_SCALARS.CLICK_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.CLICK_NONSPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: true,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ // block
+ "urlbarView-button-block": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position,
+ [TELEMETRY_SCALARS.BLOCK_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.BLOCK_NONSPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ iab_category: REMOTE_SETTINGS_RESULT.iab_category,
+ },
+ },
+ ],
+ },
+ // help
+ "urlbarView-button-help": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_NONSPONSORED_BEST_MATCH]: position,
+ [TELEMETRY_SCALARS.HELP_NONSPONSORED]: position,
+ [TELEMETRY_SCALARS.HELP_NONSPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "help",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ },
+ });
+ await QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js
new file mode 100644
index 0000000000..d151ca81ad
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js
@@ -0,0 +1,409 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests ancillary quick suggest telemetry, i.e., telemetry that's not
+ * strongly related to showing suggestions in the urlbar.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+});
+
+const REMOTE_SETTINGS_RESULTS = [
+ {
+ id: 1,
+ url: "https://example.com/sponsored",
+ title: "Sponsored suggestion",
+ keywords: ["sponsored"],
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "testadvertiser",
+ },
+];
+
+add_setup(async function () {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+
+ // Add a mock engine so we don't hit the network.
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsResults: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ });
+});
+
+// Tests telemetry recorded when toggling the
+// `suggest.quicksuggest.nonsponsored` pref:
+// * contextservices.quicksuggest enable_toggled event telemetry
+// * TelemetryEnvironment
+add_task(async function enableToggled() {
+ Services.telemetry.clearEvents();
+
+ // Toggle the suggest.quicksuggest.nonsponsored pref twice. We should get two
+ // events.
+ let enabled = UrlbarPrefs.get("suggest.quicksuggest.nonsponsored");
+ for (let i = 0; i < 2; i++) {
+ enabled = !enabled;
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", enabled);
+ QuickSuggestTestUtils.assertEvents([
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "enable_toggled",
+ object: enabled ? "enabled" : "disabled",
+ },
+ ]);
+ Assert.equal(
+ TelemetryEnvironment.currentEnvironment.settings.userPrefs[
+ "browser.urlbar.suggest.quicksuggest.nonsponsored"
+ ],
+ enabled,
+ "suggest.quicksuggest.nonsponsored is correct in TelemetryEnvironment"
+ );
+ }
+
+ // Set the main quicksuggest.enabled pref to false and toggle the
+ // suggest.quicksuggest.nonsponsored pref again. We shouldn't get any events.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.quicksuggest.enabled", false]],
+ });
+ enabled = !enabled;
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", enabled);
+ QuickSuggestTestUtils.assertEvents([]);
+ await SpecialPowers.popPrefEnv();
+
+ // Set the pref back to what it was at the start of the task.
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", !enabled);
+});
+
+// Tests telemetry recorded when toggling the `suggest.quicksuggest.sponsored`
+// pref:
+// * contextservices.quicksuggest enable_toggled event telemetry
+// * TelemetryEnvironment
+add_task(async function sponsoredToggled() {
+ Services.telemetry.clearEvents();
+
+ // Toggle the suggest.quicksuggest.sponsored pref twice. We should get two
+ // events.
+ let enabled = UrlbarPrefs.get("suggest.quicksuggest.sponsored");
+ for (let i = 0; i < 2; i++) {
+ enabled = !enabled;
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", enabled);
+ QuickSuggestTestUtils.assertEvents([
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "sponsored_toggled",
+ object: enabled ? "enabled" : "disabled",
+ },
+ ]);
+ Assert.equal(
+ TelemetryEnvironment.currentEnvironment.settings.userPrefs[
+ "browser.urlbar.suggest.quicksuggest.sponsored"
+ ],
+ enabled,
+ "suggest.quicksuggest.sponsored is correct in TelemetryEnvironment"
+ );
+ }
+
+ // Set the main quicksuggest.enabled pref to false and toggle the
+ // suggest.quicksuggest.sponsored pref again. We shouldn't get any events.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.quicksuggest.enabled", false]],
+ });
+ enabled = !enabled;
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", enabled);
+ QuickSuggestTestUtils.assertEvents([]);
+ await SpecialPowers.popPrefEnv();
+
+ // Set the pref back to what it was at the start of the task.
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", !enabled);
+});
+
+// Tests telemetry recorded when toggling the
+// `quicksuggest.dataCollection.enabled` pref:
+// * contextservices.quicksuggest data_collect_toggled event telemetry
+// * TelemetryEnvironment
+add_task(async function dataCollectionToggled() {
+ Services.telemetry.clearEvents();
+
+ // Toggle the quicksuggest.dataCollection.enabled pref twice. We should get
+ // two events.
+ let enabled = UrlbarPrefs.get("quicksuggest.dataCollection.enabled");
+ for (let i = 0; i < 2; i++) {
+ enabled = !enabled;
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", enabled);
+ QuickSuggestTestUtils.assertEvents([
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "data_collect_toggled",
+ object: enabled ? "enabled" : "disabled",
+ },
+ ]);
+ Assert.equal(
+ TelemetryEnvironment.currentEnvironment.settings.userPrefs[
+ "browser.urlbar.quicksuggest.dataCollection.enabled"
+ ],
+ enabled,
+ "quicksuggest.dataCollection.enabled is correct in TelemetryEnvironment"
+ );
+ }
+
+ // Set the main quicksuggest.enabled pref to false and toggle the data
+ // collection pref again. We shouldn't get any events.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.quicksuggest.enabled", false]],
+ });
+ enabled = !enabled;
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", enabled);
+ QuickSuggestTestUtils.assertEvents([]);
+ await SpecialPowers.popPrefEnv();
+
+ // Set the pref back to what it was at the start of the task.
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", !enabled);
+});
+
+// Tests telemetry recorded when clicking the checkbox for best match in
+// preferences UI. The telemetry will be stored as following keyed scalar.
+// scalar: browser.ui.interaction.preferences_panePrivacy
+// key: firefoxSuggestBestMatch
+add_task(async function bestmatchCheckbox() {
+ // Set the initial enabled status.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.bestMatch.enabled", true]],
+ });
+
+ // Open preferences page for best match.
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences#privacy",
+ true
+ );
+
+ for (let i = 0; i < 2; i++) {
+ Services.telemetry.clearScalars();
+
+ // Click on the checkbox.
+ const doc = gBrowser.selectedBrowser.contentDocument;
+ const checkboxId = "firefoxSuggestBestMatch";
+ const checkbox = doc.getElementById(checkboxId);
+ checkbox.scrollIntoView();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + checkboxId,
+ {},
+ gBrowser.selectedBrowser
+ );
+
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "browser.ui.interaction.preferences_panePrivacy",
+ checkboxId,
+ 1
+ );
+ }
+
+ // Clean up.
+ gBrowser.removeCurrentTab();
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests telemetry recorded when opening the learn more link for best match in
+// the preferences UI. The telemetry will be stored as following keyed scalar.
+// scalar: browser.ui.interaction.preferences_panePrivacy
+// key: firefoxSuggestBestMatchLearnMore
+add_task(async function bestmatchLearnMore() {
+ // Set the initial enabled status.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.bestMatch.enabled", true]],
+ });
+
+ // Open preferences page for best match.
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences#privacy",
+ true
+ );
+
+ // Click on the learn more link.
+ Services.telemetry.clearScalars();
+ const learnMoreLinkId = "firefoxSuggestBestMatchLearnMore";
+ const doc = gBrowser.selectedBrowser.contentDocument;
+ const link = doc.getElementById(learnMoreLinkId);
+ link.scrollIntoView();
+ const onLearnMoreOpenedByClick = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ QuickSuggest.HELP_URL
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + learnMoreLinkId,
+ {},
+ gBrowser.selectedBrowser
+ );
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "browser.ui.interaction.preferences_panePrivacy",
+ "firefoxSuggestBestMatchLearnMore",
+ 1
+ );
+ await onLearnMoreOpenedByClick;
+ gBrowser.removeCurrentTab();
+
+ // Type enter key on the learm more link.
+ Services.telemetry.clearScalars();
+ link.focus();
+ const onLearnMoreOpenedByKey = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ QuickSuggest.HELP_URL
+ );
+ await BrowserTestUtils.synthesizeKey(
+ "KEY_Enter",
+ {},
+ gBrowser.selectedBrowser
+ );
+ TelemetryTestUtils.assertKeyedScalar(
+ TelemetryTestUtils.getProcessScalars("parent", true, true),
+ "browser.ui.interaction.preferences_panePrivacy",
+ "firefoxSuggestBestMatchLearnMore",
+ 1
+ );
+ await onLearnMoreOpenedByKey;
+ gBrowser.removeCurrentTab();
+
+ // Clean up.
+ gBrowser.removeCurrentTab();
+ await SpecialPowers.popPrefEnv();
+});
+
+// Simulates the race on startup between telemetry environment initialization
+// and the initial update of the Suggest scenario. After startup is done,
+// telemetry environment should record the correct values for startup prefs.
+add_task(async function telemetryEnvironmentOnStartup() {
+ await QuickSuggestTestUtils.setScenario(null);
+
+ // Restart telemetry environment so we know it's watching its default set of
+ // prefs.
+ await TelemetryEnvironment.testCleanRestart().onInitialized();
+
+ // Get the prefs that UrlbarPrefs sets when the Suggest scenario is updated on
+ // startup. They're the union of the prefs exposed in the UI and the prefs
+ // that are set on the default branch per scenario.
+ let prefs = [
+ ...new Set([
+ ...Object.values(UrlbarPrefs.FIREFOX_SUGGEST_UI_PREFS_BY_VARIABLE),
+ ...Object.values(UrlbarPrefs.FIREFOX_SUGGEST_DEFAULT_PREFS)
+ .map(valuesByPrefName => Object.keys(valuesByPrefName))
+ .flat(),
+ ]),
+ ];
+
+ // Not all of the prefs are recorded in telemetry environment. Filter in the
+ // ones that are.
+ prefs = prefs.filter(
+ p =>
+ `browser.urlbar.${p}` in
+ TelemetryEnvironment.currentEnvironment.settings.userPrefs
+ );
+
+ info("Got startup prefs: " + JSON.stringify(prefs));
+
+ // Sanity check the expected prefs. This isn't strictly necessary since we
+ // programmatically get the prefs above, but it's an extra layer of defense,
+ // for example in case we accidentally filtered out some expected prefs above.
+ // If this fails, you might have added a startup pref but didn't update this
+ // array here.
+ Assert.deepEqual(
+ prefs.sort(),
+ [
+ "quicksuggest.dataCollection.enabled",
+ "suggest.quicksuggest.nonsponsored",
+ "suggest.quicksuggest.sponsored",
+ ],
+ "Expected startup prefs"
+ );
+
+ // Make sure the prefs don't have user values that would mask the default
+ // values.
+ for (let p of prefs) {
+ UrlbarPrefs.clear(p);
+ }
+
+ // Build a map of default values.
+ let defaultValues = Object.fromEntries(
+ prefs.map(p => [p, UrlbarPrefs.get(p)])
+ );
+
+ // Now simulate startup. Restart telemetry environment but don't wait for it
+ // to finish before calling `updateFirefoxSuggestScenario()`. This simulates
+ // startup where telemetry environment's initialization races the intial
+ // update of the Suggest scenario.
+ let environmentInitPromise =
+ TelemetryEnvironment.testCleanRestart().onInitialized();
+
+ // Update the scenario and force the startup prefs to take on values that are
+ // the inverse of what they are now.
+ await UrlbarPrefs.updateFirefoxSuggestScenario({
+ isStartup: true,
+ scenario: "online",
+ defaultPrefs: {
+ online: Object.fromEntries(
+ Object.entries(defaultValues).map(([p, value]) => [p, !value])
+ ),
+ },
+ });
+
+ // At this point telemetry environment should be done initializing since
+ // `updateFirefoxSuggestScenario()` waits for it, but await our promise now.
+ await environmentInitPromise;
+
+ // TelemetryEnvironment should have cached the new values.
+ for (let [p, value] of Object.entries(defaultValues)) {
+ let expected = !value;
+ Assert.strictEqual(
+ TelemetryEnvironment.currentEnvironment.settings.userPrefs[
+ `browser.urlbar.${p}`
+ ],
+ expected,
+ `Check 1: ${p} is ${expected} in TelemetryEnvironment`
+ );
+ }
+
+ // Simulate another startup and set all prefs back to their original default
+ // values.
+ environmentInitPromise =
+ TelemetryEnvironment.testCleanRestart().onInitialized();
+
+ await UrlbarPrefs.updateFirefoxSuggestScenario({
+ isStartup: true,
+ scenario: "online",
+ defaultPrefs: {
+ online: defaultValues,
+ },
+ });
+
+ await environmentInitPromise;
+
+ // TelemetryEnvironment should have cached the new (original) values.
+ for (let [p, value] of Object.entries(defaultValues)) {
+ let expected = value;
+ Assert.strictEqual(
+ TelemetryEnvironment.currentEnvironment.settings.userPrefs[
+ `browser.urlbar.${p}`
+ ],
+ expected,
+ `Check 2: ${p} is ${expected} in TelemetryEnvironment`
+ );
+ }
+
+ await TelemetryEnvironment.testCleanRestart().onInitialized();
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js
new file mode 100644
index 0000000000..498b942a12
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js
@@ -0,0 +1,367 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests primary telemetry for sponsored suggestions.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+});
+
+const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;
+
+const REMOTE_SETTINGS_RESULT = {
+ id: 1,
+ url: "https://example.com/sponsored",
+ title: "Sponsored suggestion",
+ keywords: ["sponsored"],
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "testadvertiser",
+};
+
+const suggestion_type = "sponsored";
+const index = 1;
+const position = index + 1;
+
+add_setup(async function () {
+ await setUpTelemetryTest({
+ remoteSettingsResults: [
+ {
+ type: "data",
+ attachment: [REMOTE_SETTINGS_RESULT],
+ },
+ ],
+ });
+});
+
+// sponsored
+add_task(async function sponsored() {
+ let match_type = "firefox-suggest";
+
+ // Make sure `improve_suggest_experience_checked` is recorded correctly
+ // depending on the value of the related pref.
+ for (let improve_suggest_experience_checked of [false, true]) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.urlbar.quicksuggest.dataCollection.enabled",
+ improve_suggest_experience_checked,
+ ],
+ ],
+ });
+ await doTelemetryTest({
+ index,
+ suggestion: REMOTE_SETTINGS_RESULT,
+ // impression-only
+ impressionOnly: {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ ping: {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ },
+ selectables: {
+ // click
+ "urlbarView-row-inner": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ [TELEMETRY_SCALARS.CLICK_SPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: true,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ // block
+ "urlbarView-button-block": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ [TELEMETRY_SCALARS.BLOCK_SPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ iab_category: REMOTE_SETTINGS_RESULT.iab_category,
+ },
+ },
+ ],
+ },
+ // help
+ "urlbarView-button-help": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ [TELEMETRY_SCALARS.HELP_SPONSORED]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "help",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ },
+ });
+ await SpecialPowers.popPrefEnv();
+ }
+});
+
+// sponsored best match
+add_task(async function sponsoredBestMatch() {
+ let match_type = "best-match";
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.bestMatch.enabled", true]],
+ });
+ await QuickSuggestTestUtils.setConfig(
+ QuickSuggestTestUtils.BEST_MATCH_CONFIG
+ );
+ await doTelemetryTest({
+ index,
+ suggestion: REMOTE_SETTINGS_RESULT,
+ // impression-only
+ impressionOnly: {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ ping: {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ },
+ selectables: {
+ // click
+ "urlbarView-row-inner": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position,
+ [TELEMETRY_SCALARS.CLICK_SPONSORED]: position,
+ [TELEMETRY_SCALARS.CLICK_SPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: true,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ // block
+ "urlbarView-button-block": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position,
+ [TELEMETRY_SCALARS.BLOCK_SPONSORED]: position,
+ [TELEMETRY_SCALARS.BLOCK_SPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ payload: {
+ match_type,
+ position,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ iab_category: REMOTE_SETTINGS_RESULT.iab_category,
+ },
+ },
+ ],
+ },
+ // help
+ "urlbarView-button-help": {
+ scalars: {
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: position,
+ [TELEMETRY_SCALARS.IMPRESSION_SPONSORED_BEST_MATCH]: position,
+ [TELEMETRY_SCALARS.HELP_SPONSORED]: position,
+ [TELEMETRY_SCALARS.HELP_SPONSORED_BEST_MATCH]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "help",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ pings: [
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
+ payload: {
+ match_type,
+ position,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ },
+ });
+ await QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js
new file mode 100644
index 0000000000..b60fa9fe85
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests primary telemetry for weather suggestions.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs",
+});
+
+const suggestion_type = "weather";
+const match_type = "firefox-suggest";
+const index = 1;
+const position = index + 1;
+
+const { TELEMETRY_SCALARS: WEATHER_SCALARS } = UrlbarProviderWeather;
+const { WEATHER_SUGGESTION: suggestion, WEATHER_RS_DATA } = MerinoTestUtils;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Make sure quick actions are disabled because showing them in the top
+ // sites view interferes with this test.
+ ["browser.urlbar.suggest.quickactions", false],
+ ],
+ });
+
+ await setUpTelemetryTest({
+ suggestions: [],
+ remoteSettingsResults: [
+ {
+ type: "weather",
+ weather: WEATHER_RS_DATA,
+ },
+ ],
+ });
+ await MerinoTestUtils.initWeather();
+ await updateTopSitesAndAwaitChanged();
+});
+
+add_task(async function () {
+ await doTelemetryTest({
+ index,
+ suggestion,
+ providerName: UrlbarProviderWeather.name,
+ showSuggestion: async () => {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: MerinoTestUtils.WEATHER_KEYWORD,
+ });
+ },
+ teardown: async () => {
+ // Picking the block button sets this pref to false and disables weather
+ // suggestions. We need to flip it back to true and wait for the
+ // suggestion to be fetched again before continuing to the next selectable
+ // test. The view also also stay open, so close it afterward.
+ if (!UrlbarPrefs.get("suggest.weather")) {
+ await UrlbarTestUtils.promisePopupClose(window);
+ gURLBar.handleRevert();
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ UrlbarPrefs.clear("suggest.weather");
+ await fetchPromise;
+ }
+ },
+ // impression-only
+ impressionOnly: {
+ scalars: {
+ [WEATHER_SCALARS.IMPRESSION]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "impression_only",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ selectables: {
+ // click
+ "urlbarView-row-inner": {
+ scalars: {
+ [WEATHER_SCALARS.IMPRESSION]: position,
+ [WEATHER_SCALARS.CLICK]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "click",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ // block
+ "urlbarView-button-block": {
+ scalars: {
+ [WEATHER_SCALARS.IMPRESSION]: position,
+ [WEATHER_SCALARS.BLOCK]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ // help
+ "urlbarView-button-help": {
+ scalars: {
+ [WEATHER_SCALARS.IMPRESSION]: position,
+ [WEATHER_SCALARS.HELP]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "help",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ },
+ });
+});
+
+async function updateTopSitesAndAwaitChanged() {
+ let url = "http://mochi.test:8888/topsite";
+ for (let i = 0; i < 5; i++) {
+ await PlacesTestUtils.addVisits(url);
+ }
+
+ info("Updating top sites and awaiting newtab-top-sites-changed");
+ let changedPromise = TestUtils.topicObserved("newtab-top-sites-changed").then(
+ () => info("Observed newtab-top-sites-changed")
+ );
+ await updateTopSites(sites => sites?.length);
+ await changedPromise;
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js
new file mode 100644
index 0000000000..049b25dd09
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js
@@ -0,0 +1,379 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Browser test for the weather suggestion.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs",
+});
+
+add_setup(async function () {
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsResults: [
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ],
+ });
+ await MerinoTestUtils.initWeather();
+});
+
+// This test ensures the browser navigates to the weather webpage after
+// the weather result is selected.
+add_task(async function test_weather_result_selection() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false
+ );
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: MerinoTestUtils.WEATHER_KEYWORD,
+ });
+
+ info(`Select the weather result`);
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+
+ info(`Navigate to the weather url`);
+ EventUtils.synthesizeKey("KEY_Enter");
+
+ await browserLoadedPromise;
+
+ Assert.equal(
+ gBrowser.currentURI.spec,
+ "https://example.com/weather",
+ "Assert the page navigated to the weather webpage after selecting the weather result."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await PlacesUtils.history.clear();
+});
+
+// Does a search, clicks the "Show less frequently" result menu command, and
+// repeats both steps until the min keyword length cap is reached.
+add_task(async function showLessFrequentlyCapReached_manySearches() {
+ // Set up a min keyword length and cap.
+ await QuickSuggestTestUtils.setRemoteSettingsResults([
+ {
+ type: "weather",
+ weather: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ min_keyword_length_cap: 4,
+ },
+ },
+ ]);
+
+ // Trigger the suggestion.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "wea",
+ });
+
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.result.providerName,
+ UrlbarProviderWeather.name,
+ "Weather suggestion should be present at expected index after 'wea' search"
+ );
+
+ // Click the command.
+ let command = "show_less_frequently";
+ await UrlbarTestUtils.openResultMenuAndClickItem(window, command, {
+ resultIndex,
+ });
+
+ Assert.ok(
+ gURLBar.view.isOpen,
+ "The view should remain open clicking the command"
+ );
+ Assert.ok(
+ details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should have feedback acknowledgment after clicking command"
+ );
+ Assert.equal(
+ UrlbarPrefs.get("weather.minKeywordLength"),
+ 4,
+ "weather.minKeywordLength should be incremented once"
+ );
+
+ // Do the same search again. The suggestion should not appear.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "wea",
+ });
+
+ for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.notEqual(
+ details.result.providerName,
+ UrlbarProviderWeather.name,
+ `Weather suggestion should be absent (checking index ${i})`
+ );
+ }
+
+ // Do a search using one more character. The suggestion should appear.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "weat",
+ });
+
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.result.providerName,
+ UrlbarProviderWeather.name,
+ "Weather suggestion should be present at expected index after 'weat' search"
+ );
+ Assert.ok(
+ !details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should not have feedback acknowledgment after 'weat' search"
+ );
+
+ // Since the cap has been reached, the command should no longer appear in the
+ // result menu.
+ await UrlbarTestUtils.openResultMenu(window, { resultIndex });
+ let menuitem = gURLBar.view.resultMenu.querySelector(
+ `menuitem[data-command=${command}]`
+ );
+ Assert.ok(!menuitem, "Menuitem should be absent");
+ gURLBar.view.resultMenu.hidePopup(true);
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await QuickSuggestTestUtils.setRemoteSettingsResults([
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ]);
+ UrlbarPrefs.clear("weather.minKeywordLength");
+});
+
+// Repeatedly clicks the "Show less frequently" result menu command after doing
+// a single search until the min keyword length cap is reached.
+add_task(async function showLessFrequentlyCapReached_oneSearch() {
+ // Set up a min keyword length and cap.
+ await QuickSuggestTestUtils.setRemoteSettingsResults([
+ {
+ type: "weather",
+ weather: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ min_keyword_length_cap: 6,
+ },
+ },
+ ]);
+
+ // Trigger the suggestion.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "wea",
+ });
+
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.result.providerName,
+ UrlbarProviderWeather.name,
+ "Weather suggestion should be present at expected index after 'wea' search"
+ );
+
+ let command = "show_less_frequently";
+
+ for (let i = 0; i < 3; i++) {
+ await UrlbarTestUtils.openResultMenuAndClickItem(window, command, {
+ resultIndex,
+ openByMouse: true,
+ });
+
+ Assert.ok(
+ gURLBar.view.isOpen,
+ "The view should remain open clicking the command"
+ );
+ Assert.ok(
+ details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should have feedback acknowledgment after clicking command"
+ );
+ Assert.equal(
+ UrlbarPrefs.get("weather.minKeywordLength"),
+ 4 + i,
+ "weather.minKeywordLength should be incremented once"
+ );
+ }
+
+ let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({
+ window,
+ command,
+ resultIndex,
+ });
+ Assert.ok(
+ !menuitem,
+ "The menuitem should not exist after the cap is reached"
+ );
+
+ gURLBar.view.resultMenu.hidePopup(true);
+ await UrlbarTestUtils.promisePopupClose(window);
+ await QuickSuggestTestUtils.setRemoteSettingsResults([
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ]);
+ UrlbarPrefs.clear("weather.minKeywordLength");
+});
+
+// Tests the "Not interested" result menu dismissal command.
+add_task(async function notInterested() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: MerinoTestUtils.WEATHER_KEYWORD,
+ });
+ await doDismissTest("not_interested");
+});
+
+// Tests the "Not relevant" result menu dismissal command.
+add_task(async function notRelevant() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: MerinoTestUtils.WEATHER_KEYWORD,
+ });
+ await doDismissTest("not_relevant");
+});
+
+async function doDismissTest(command) {
+ let resultCount = UrlbarTestUtils.getResultCount(window);
+
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.result.providerName,
+ UrlbarProviderWeather.name,
+ "Weather suggestion should be present"
+ );
+
+ // Click the command.
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command],
+ { resultIndex, openByMouse: true }
+ );
+
+ Assert.ok(
+ !UrlbarPrefs.get("suggest.weather"),
+ "suggest.weather pref should be set to false after dismissal"
+ );
+
+ // The row should be a tip now.
+ Assert.ok(gURLBar.view.isOpen, "The view should remain open after dismissal");
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ resultCount,
+ "The result count should not haved changed after dismissal"
+ );
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.type,
+ UrlbarUtils.RESULT_TYPE.TIP,
+ "Row should be a tip after dismissal"
+ );
+ Assert.equal(
+ details.result.payload.type,
+ "dismissalAcknowledgment",
+ "Tip type should be dismissalAcknowledgment"
+ );
+ Assert.ok(
+ !details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should not have feedback acknowledgment after dismissal"
+ );
+
+ // Get the dismissal acknowledgment's "Got it" button and click it.
+ let gotItButton = UrlbarTestUtils.getButtonForResultIndex(
+ window,
+ "0",
+ resultIndex
+ );
+ Assert.ok(gotItButton, "Row should have a 'Got it' button");
+ EventUtils.synthesizeMouseAtCenter(gotItButton, {}, window);
+
+ // The view should remain open and the tip row should be gone.
+ Assert.ok(
+ gURLBar.view.isOpen,
+ "The view should remain open clicking the 'Got it' button"
+ );
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ resultCount - 1,
+ "The result count should be one less after clicking 'Got it' button"
+ );
+ for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.ok(
+ details.type != UrlbarUtils.RESULT_TYPE.TIP &&
+ details.result.providerName != UrlbarProviderWeather.name,
+ "Tip result and weather result should not be present"
+ );
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ // Enable the weather suggestion again and wait for it to be fetched.
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ UrlbarPrefs.clear("suggest.weather");
+ info("Waiting for weather fetch after re-enabling the suggestion");
+ await fetchPromise;
+ info("Got weather fetch");
+}
+
+// Tests the "Report inaccurate location" result menu command immediately
+// followed by a dismissal command to make sure other commands still work
+// properly while the urlbar session remains ongoing.
+add_task(async function inaccurateLocationAndDismissal() {
+ await doSessionOngoingCommandTest("inaccurate_location");
+});
+
+// Tests the "Show less frequently" result menu command immediately followed by
+// a dismissal command to make sure other commands still work properly while the
+// urlbar session remains ongoing.
+add_task(async function showLessFrequentlyAndDismissal() {
+ await doSessionOngoingCommandTest("show_less_frequently");
+ UrlbarPrefs.clear("weather.minKeywordLength");
+});
+
+async function doSessionOngoingCommandTest(command) {
+ // Trigger the suggestion.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: MerinoTestUtils.WEATHER_KEYWORD,
+ });
+
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.result.providerName,
+ UrlbarProviderWeather.name,
+ "Weather suggestion should be present at expected index after search"
+ );
+
+ // Click the command.
+ await UrlbarTestUtils.openResultMenuAndClickItem(window, command, {
+ resultIndex,
+ });
+
+ Assert.ok(
+ gURLBar.view.isOpen,
+ "The view should remain open clicking the command"
+ );
+ Assert.ok(
+ details.element.row.hasAttribute("feedback-acknowledgment"),
+ "Row should have feedback acknowledgment after clicking command"
+ );
+
+ info("Doing dismissal");
+ await doDismissTest("not_interested");
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/head.js b/browser/components/urlbar/tests/quicksuggest/browser/head.js
new file mode 100644
index 0000000000..07979080fd
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/head.js
@@ -0,0 +1,569 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let sandbox;
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/components/urlbar/tests/browser/head-common.js",
+ this
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.jsm",
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ UrlbarProviderQuickSuggest:
+ "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => {
+ const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/QuickSuggestTestUtils.sys.mjs"
+ );
+ module.init(this);
+ return module;
+});
+
+XPCOMUtils.defineLazyGetter(this, "MerinoTestUtils", () => {
+ const { MerinoTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/MerinoTestUtils.sys.mjs"
+ );
+ module.init(this);
+ return module;
+});
+
+registerCleanupFunction(async () => {
+ // Ensure the popup is always closed at the end of each test to avoid
+ // interfering with the next test.
+ await UrlbarTestUtils.promisePopupClose(window);
+});
+
+/**
+ * Updates the Top Sites feed.
+ *
+ * @param {Function} condition
+ * A callback that returns true after Top Sites are successfully updated.
+ * @param {boolean} searchShortcuts
+ * True if Top Sites search shortcuts should be enabled.
+ */
+async function updateTopSites(condition, searchShortcuts = false) {
+ // Toggle the pref to clear the feed cache and force an update.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.discoverystream.endpointSpocsClear",
+ "",
+ ],
+ ["browser.newtabpage.activity-stream.feeds.system.topsites", false],
+ ["browser.newtabpage.activity-stream.feeds.system.topsites", true],
+ [
+ "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts",
+ searchShortcuts,
+ ],
+ ],
+ });
+
+ // Wait for the feed to be updated.
+ await TestUtils.waitForCondition(() => {
+ let sites = AboutNewTab.getTopSites();
+ return condition(sites);
+ }, "Waiting for top sites to be updated");
+}
+
+/**
+ * Call this in your setup task if you use `doTelemetryTest()`.
+ *
+ * @param {object} options
+ * Options
+ * @param {Array} options.remoteSettingsResults
+ * Array of remote settings result objects. If not given, no suggestions
+ * will be present in remote settings.
+ * @param {Array} options.merinoSuggestions
+ * Array of Merino suggestion objects. If given, this function will start
+ * the mock Merino server and set `quicksuggest.dataCollection.enabled` to
+ * true so that `UrlbarProviderQuickSuggest` will fetch suggestions from it.
+ * Otherwise Merino will not serve suggestions, but you can still set up
+ * Merino without using this function by using `MerinoTestUtils` directly.
+ * @param {Array} options.config
+ * Quick suggest will be initialized with this config. Leave undefined to use
+ * the default config. See `QuickSuggestTestUtils` for details.
+ */
+async function setUpTelemetryTest({
+ remoteSettingsResults,
+ merinoSuggestions = null,
+ config = QuickSuggestTestUtils.DEFAULT_CONFIG,
+}) {
+ if (UrlbarPrefs.get("resultMenu")) {
+ todo(
+ false,
+ "telemetry for the result menu to be implemented in bug 1790020"
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.resultMenu", false]],
+ });
+ }
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Enable blocking on primary sponsored and nonsponsored suggestions so we
+ // can test the block button.
+ ["browser.urlbar.quicksuggest.blockingEnabled", true],
+ ["browser.urlbar.bestMatch.blockingEnabled", true],
+ // Switch-to-tab results can sometimes appear after the test clicks a help
+ // button and closes the new tab, which interferes with the expected
+ // indexes of quick suggest results, so disable them.
+ ["browser.urlbar.suggest.openpage", false],
+ // Disable the persisted-search-terms search tip because it can interfere.
+ ["browser.urlbar.tipShownCount.searchTip_persist", 999],
+ ],
+ });
+
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+
+ // Add a mock engine so we don't hit the network.
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsResults,
+ merinoSuggestions,
+ config,
+ });
+}
+
+/**
+ * Main entry point for testing primary telemetry for quick suggest suggestions:
+ * impressions, clicks, helps, and blocks. This can be used to declaratively
+ * test all primary telemetry for any suggestion type.
+ *
+ * @param {object} options
+ * Options
+ * @param {number} options.index
+ * The expected index of the suggestion in the results list.
+ * @param {object} options.suggestion
+ * The suggestion being tested.
+ * @param {object} options.impressionOnly
+ * An object describing the expected impression-only telemetry, i.e.,
+ * telemetry recorded when an impression occurs but not a click. It must have
+ * the following properties:
+ * {object} scalars
+ * An object that maps expected scalar names to values.
+ * {object} event
+ * The expected recorded event.
+ * {object} ping
+ * The expected recorded custom telemetry ping. If no ping is expected,
+ * leave this undefined or pass null.
+ * @param {object} options.selectables
+ * An object describing the telemetry that's expected to be recorded when each
+ * selectable element in the suggestion's row is picked. This object maps HTML
+ * class names to objects. Each property's name must be an HTML class name
+ * that uniquely identifies a selectable element within the row. The value
+ * must be an object that describes the telemetry that's expected to be
+ * recorded when that element is picked, and this inner object must have the
+ * following properties:
+ * {object} scalars
+ * An object that maps expected scalar names to values.
+ * {object} event
+ * The expected recorded event.
+ * {Array} pings
+ * A list of expected recorded custom telemetry pings. If no pings are
+ * expected, pass an empty array.
+ * @param {string} options.providerName
+ * The name of the provider that is expected to create the UrlbarResult for
+ * the suggestion.
+ * @param {Function} options.teardown
+ * If given, this function will be called after each selectable test. If
+ * picking an element causes side effects that need to be cleaned up before
+ * starting the next selectable test, they can be cleaned up here.
+ * @param {Function} options.showSuggestion
+ * This function should open the view and show the suggestion.
+ */
+async function doTelemetryTest({
+ index,
+ suggestion,
+ impressionOnly,
+ selectables,
+ providerName = UrlbarProviderQuickSuggest.name,
+ teardown = null,
+ showSuggestion = () =>
+ UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ // If the suggestion object is a remote settings result, it will have a
+ // `keywords` property. Otherwise the suggestion object must be a Merino
+ // suggestion, and the search string doesn't matter in that case because
+ // the mock Merino server will be set up to return suggestions regardless.
+ value: suggestion.keywords?.[0] || "test",
+ fireInputEvent: true,
+ }),
+}) {
+ // Do the impression-only test. It will return the `classList` values of all
+ // the selectable elements in the row so we can use them below.
+ let selectableClassLists = await doImpressionOnlyTest({
+ index,
+ suggestion,
+ providerName,
+ showSuggestion,
+ expected: impressionOnly,
+ });
+ if (!selectableClassLists) {
+ Assert.ok(
+ false,
+ "Impression test didn't complete successfully, stopping telemetry test"
+ );
+ return;
+ }
+
+ info(
+ "Got classLists of actual selectable elements in the row: " +
+ JSON.stringify(selectableClassLists)
+ );
+
+ let allMatchedExpectedClasses = new Set();
+
+ // For each actual selectable element in the row, do a selectable test by
+ // picking the element and checking telemetry.
+ for (let classList of selectableClassLists) {
+ info(
+ "Setting up selectable test for actual element with classList " +
+ JSON.stringify(classList)
+ );
+
+ // Each of the actual selectable elements should match exactly one of the
+ // test's expected selectable classes.
+ //
+ // * If an element doesn't match any expected class, then the test does not
+ // account for that element, which is an error in the test.
+ // * If an element matches more than one expected class, then the expected
+ // class is not specific enough, which is also an error in the test.
+
+ // Collect all the expected classes that match the actual element.
+ let matchingExpectedClasses = Object.keys(selectables).filter(className =>
+ classList.includes(className)
+ );
+
+ if (!matchingExpectedClasses.length) {
+ Assert.ok(
+ false,
+ "Actual selectable element doesn't match any expected classes. The element's classList is " +
+ JSON.stringify(classList)
+ );
+ continue;
+ }
+ if (matchingExpectedClasses.length > 1) {
+ Assert.ok(
+ false,
+ "Actual selectable element matches multiple expected classes. The element's classList is " +
+ JSON.stringify(classList)
+ );
+ continue;
+ }
+
+ let className = matchingExpectedClasses[0];
+ allMatchedExpectedClasses.add(className);
+
+ await doSelectableTest({
+ suggestion,
+ providerName,
+ showSuggestion,
+ index,
+ className,
+ expected: selectables[className],
+ });
+
+ if (teardown) {
+ info("Calling teardown");
+ await teardown();
+ info("Finished teardown");
+ }
+ }
+
+ // Finally, if an expected class doesn't match any actual element, then the
+ // test expects an element to be picked that either isn't present or isn't
+ // selectable, which is an error in the test.
+ Assert.deepEqual(
+ Object.keys(selectables).filter(
+ className => !allMatchedExpectedClasses.has(className)
+ ),
+ [],
+ "There should be no expected classes that didn't match actual selectable elements"
+ );
+}
+
+/**
+ * Helper for `doTelemetryTest()` that does an impression-only test.
+ *
+ * @param {object} options
+ * Options
+ * @param {number} options.index
+ * The expected index of the suggestion in the results list.
+ * @param {object} options.suggestion
+ * The suggestion being tested.
+ * @param {string} options.providerName
+ * The name of the provider that is expected to create the UrlbarResult for
+ * the suggestion.
+ * @param {object} options.expected
+ * An object describing the expected impression-only telemetry. It must have
+ * the following properties:
+ * {object} scalars
+ * An object that maps expected scalar names to values.
+ * {object} event
+ * The expected recorded event.
+ * {object} ping
+ * The expected recorded custom telemetry ping. If no ping is expected,
+ * leave this undefined or pass null.
+ * @param {Function} options.showSuggestion
+ * This function should open the view and show the suggestion.
+ * @returns {Array}
+ * The `classList` values of all the selectable elements in the suggestion's
+ * row. Each item in this array is a selectable element's `classList` that has
+ * been converted to an array of strings.
+ */
+async function doImpressionOnlyTest({
+ index,
+ suggestion,
+ providerName,
+ expected,
+ showSuggestion,
+}) {
+ info("Starting impression-only test");
+
+ Services.telemetry.clearEvents();
+ let { spy, spyCleanup } = QuickSuggestTestUtils.createTelemetryPingSpy();
+
+ info("Showing suggestion");
+ await showSuggestion();
+
+ // Get the suggestion row.
+ let row = await validateSuggestionRow(index, suggestion, providerName);
+ if (!row) {
+ Assert.ok(
+ false,
+ "Couldn't get suggestion row, stopping impression-only test"
+ );
+ await spyCleanup();
+ return null;
+ }
+
+ // We need to get a different selectable row so we can pick it to trigger
+ // impression-only telemetry. For simplicity we'll look for a row that will
+ // load a URL when picked. We'll also verify no other rows are from the
+ // expected provider.
+ let otherRow;
+ let rowCount = UrlbarTestUtils.getResultCount(window);
+ for (let i = 0; i < rowCount; i++) {
+ if (i != index) {
+ let r = await UrlbarTestUtils.waitForAutocompleteResultAt(window, i);
+ Assert.notEqual(
+ r.result.providerName,
+ providerName,
+ "No other row should be from expected provider: index = " + i
+ );
+ if (
+ !otherRow &&
+ (r.result.payload.url ||
+ (r.result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
+ (r.result.payload.query || r.result.payload.suggestion))) &&
+ r.hasAttribute("row-selectable")
+ ) {
+ otherRow = r;
+ }
+ }
+ }
+ if (!otherRow) {
+ Assert.ok(
+ false,
+ "Couldn't get a different selectable row with a URL, stopping impression-only test"
+ );
+ await spyCleanup();
+ return null;
+ }
+
+ // Collect the `classList` values for all selectable elements in the row.
+ let selectableClassLists = [];
+ let selectables = row.querySelectorAll(":is([selectable], [role=button])");
+ for (let element of selectables) {
+ selectableClassLists.push([...element.classList]);
+ }
+
+ // Pick the different row. Assumptions:
+ // * The middle of the row is selectable
+ // * Picking the row will load a page
+ info("Clicking different row and waiting for view to close");
+ let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await UrlbarTestUtils.promisePopupClose(window, () =>
+ EventUtils.synthesizeMouseAtCenter(otherRow, {})
+ );
+
+ info("Waiting for page to load after clicking different row");
+ await loadPromise;
+
+ // Check telemetry.
+ info("Checking scalars. Expected: " + JSON.stringify(expected.scalars));
+ QuickSuggestTestUtils.assertScalars(expected.scalars);
+
+ info("Checking events. Expected: " + JSON.stringify([expected.event]));
+ QuickSuggestTestUtils.assertEvents([expected.event]);
+
+ let expectedPings = expected.ping ? [expected.ping] : [];
+ info("Checking pings. Expected: " + JSON.stringify(expectedPings));
+ QuickSuggestTestUtils.assertPings(spy, expectedPings);
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+ await UrlbarTestUtils.formHistory.clear();
+ await spyCleanup();
+
+ info("Finished impression-only test");
+
+ return selectableClassLists;
+}
+
+/**
+ * Helper for `doTelemetryTest()` that picks a selectable element in a
+ * suggestion's row and checks telemetry.
+ *
+ * @param {object} options
+ * Options
+ * @param {number} options.index
+ * The expected index of the suggestion in the results list.
+ * @param {object} options.suggestion
+ * The suggestion being tested.
+ * @param {string} options.providerName
+ * The name of the provider that is expected to create the UrlbarResult for
+ * the suggestion.
+ * @param {string} options.className
+ * An HTML class name that should uniquely identify the selectable element
+ * within its row.
+ * @param {object} options.expected
+ * An object describing the telemetry that's expected to be recorded when the
+ * selectable element is picked. It must have the following properties:
+ * {object} scalars
+ * An object that maps expected scalar names to values.
+ * {object} event
+ * The expected recorded event.
+ * {Array} pings
+ * A list of expected recorded custom telemetry pings. If no pings are
+ * expected, leave this undefined or pass an empty array.
+ * @param {Function} options.showSuggestion
+ * This function should open the view and show the suggestion.
+ */
+async function doSelectableTest({
+ index,
+ suggestion,
+ providerName,
+ className,
+ expected,
+ showSuggestion,
+}) {
+ info("Starting selectable test: " + JSON.stringify({ className }));
+
+ Services.telemetry.clearEvents();
+ let { spy, spyCleanup } = QuickSuggestTestUtils.createTelemetryPingSpy();
+
+ info("Showing suggestion");
+ await showSuggestion();
+
+ let row = await validateSuggestionRow(index, suggestion, providerName);
+ if (!row) {
+ Assert.ok(false, "Couldn't get suggestion row, stopping selectable test");
+ await spyCleanup();
+ return;
+ }
+
+ let element = row.querySelector("." + className);
+ Assert.ok(element, "Sanity check: Target selectable element should exist");
+
+ let loadPromise;
+ if (className == "urlbarView-row-inner") {
+ // We assume clicking the row-inner will cause a page to load in the current
+ // browser.
+ loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ } else if (className == "urlbarView-button-help") {
+ loadPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ }
+
+ info("Clicking element: " + className);
+ EventUtils.synthesizeMouseAtCenter(element, {});
+
+ if (loadPromise) {
+ info("Waiting for load");
+ await loadPromise;
+ await TestUtils.waitForTick();
+ if (className == "urlbarView-button-help") {
+ info("Closing help tab");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ }
+
+ info("Checking scalars. Expected: " + JSON.stringify(expected.scalars));
+ QuickSuggestTestUtils.assertScalars(expected.scalars);
+
+ info("Checking events. Expected: " + JSON.stringify([expected.event]));
+ QuickSuggestTestUtils.assertEvents([expected.event]);
+
+ let expectedPings = expected.pings ?? [];
+ info("Checking pings. Expected: " + JSON.stringify(expectedPings));
+ QuickSuggestTestUtils.assertPings(spy, expectedPings);
+
+ if (className == "urlbarView-button-block") {
+ await QuickSuggest.blockedSuggestions.clear();
+ }
+ await PlacesUtils.history.clear();
+ await spyCleanup();
+
+ info("Finished selectable test: " + JSON.stringify({ className }));
+}
+
+/**
+ * Gets a row in the view, which is assumed to be open, and asserts that it's a
+ * particular quick suggest row. If it is, the row is returned. If it's not,
+ * null is returned.
+ *
+ * @param {number} index
+ * The expected index of the quick suggest row.
+ * @param {object} suggestion
+ * The expected suggestion.
+ * @param {string} providerName
+ * The name of the provider that is expected to create the UrlbarResult for
+ * the suggestion.
+ * @returns {Element}
+ * If the row is the expected suggestion, the row element is returned.
+ * Otherwise null is returned.
+ */
+async function validateSuggestionRow(index, suggestion, providerName) {
+ let rowCount = UrlbarTestUtils.getResultCount(window);
+ Assert.less(
+ index,
+ rowCount,
+ "Expected suggestion row index should be < row count"
+ );
+ if (rowCount <= index) {
+ return null;
+ }
+
+ let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, index);
+ Assert.equal(
+ row.result.providerName,
+ providerName,
+ "Expected suggestion row should be from expected provider"
+ );
+ Assert.equal(
+ row.result.payload.url,
+ suggestion.url,
+ "The suggestion row should represent the expected suggestion"
+ );
+ if (
+ row.result.providerName != providerName ||
+ row.result.payload.url != suggestion.url
+ ) {
+ return null;
+ }
+
+ return row;
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs
new file mode 100644
index 0000000000..145392fcf2
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.sjs
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let gTimer;
+
+function handleRequest(req, resp) {
+ // Parse the query params. If the params aren't in the form "foo=bar", then
+ // treat the entire query string as a search string.
+ let params = req.queryString.split("&").reduce((memo, pair) => {
+ let [key, val] = pair.split("=");
+ if (!val) {
+ // This part isn't in the form "foo=bar". Treat it as the search string
+ // (the "query").
+ val = key;
+ key = "query";
+ }
+ memo[decode(key)] = decode(val);
+ return memo;
+ }, {});
+
+ let timeout = parseInt(params.timeout);
+ if (timeout) {
+ // Write the response after a timeout.
+ resp.processAsync();
+ gTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ gTimer.init(
+ () => {
+ writeResponse(params, resp);
+ resp.finish();
+ },
+ timeout,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ return;
+ }
+
+ writeResponse(params, resp);
+}
+
+function writeResponse(params, resp) {
+ // Echo back the search string with "foo" and "bar" appended.
+ let suffixes = ["foo", "bar"];
+ if (params.count) {
+ // Add more suffixes.
+ let serial = 0;
+ while (suffixes.length < params.count) {
+ suffixes.push(++serial);
+ }
+ }
+ let data = [params.query, suffixes.map(s => params.query + s)];
+ resp.setHeader("Content-Type", "application/json", false);
+ resp.write(JSON.stringify(data));
+}
+
+function decode(str) {
+ return decodeURIComponent(str.replace(/\+/g, encodeURIComponent(" ")));
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml
new file mode 100644
index 0000000000..142c91849c
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/searchSuggestionEngine.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/urlbar/tests/browser/searchSuggestionEngine.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform">
+ <Param name="terms" value="{searchTerms}"/>
+</Url>
+</SearchPlugin>
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml b/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml
new file mode 100644
index 0000000000..67303f19ac
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/subdialog.xhtml
@@ -0,0 +1,14 @@
+<?xml version="1.0"?>
+
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="Sample sub-dialog">
+<dialog id="subDialog">
+ <description id="desc">A sample sub-dialog for testing</description>
+</dialog>
+</window>
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]