summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/tests/quicksuggest
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/urlbar/tests/quicksuggest')
-rw-r--r--browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs809
-rw-r--r--browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs915
-rw-r--r--browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs619
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser.toml68
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js166
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js443
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js252
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js2099
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js410
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js230
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js138
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js1569
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js435
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js429
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js116
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js221
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js482
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js346
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js236
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js298
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js408
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js158
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js426
-rw-r--r--browser/components/urlbar/tests/quicksuggest/browser/head.js693
-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.js911
-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.js1661
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js558
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js103
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js3907
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js190
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js574
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js173
-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.js285
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js127
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js531
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js487
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js670
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js192
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js842
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js244
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js293
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_weather.js1402
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js1503
-rw-r--r--browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml51
51 files changed, 29646 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..6cda9bb9a7
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs
@@ -0,0 +1,809 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+});
+
+import { HttpServer } from "resource://testing-common/httpd.sys.mjs";
+
+// 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],
+ min_keyword_length: 3,
+ score: "0.29",
+};
+
+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
+
+const GEOLOCATION_DATA = {
+ provider: "geolocation",
+ title: "",
+ url: "https://merino.services.mozilla.com/",
+ is_sponsored: false,
+ score: 0,
+ custom_details: {
+ geolocation: {
+ country: "Japan",
+ region: "Kanagawa",
+ city: "Yokohama",
+ },
+ },
+};
+
+/**
+ * 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 {object}
+ * Mock geolocation data.
+ */
+ get GEOLOCATION() {
+ return { ...GEOLOCATION_DATA.custom_details.geolocation };
+ }
+
+ /**
+ * @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.
+ if (!client) {
+ this.Assert.ok(
+ !latencyStopwatchRunning,
+ "Client is null, latency stopwatch should not be expected to be running"
+ );
+ } else {
+ 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() {
+ this.info("MockMerinoServer initializing weather, starting server");
+ await this.server.start();
+ this.info("MockMerinoServer initializing weather, server now started");
+ 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.
+ this.info("MockMerinoServer initializing weather, waiting for fetch");
+ let fetchPromise = lazy.QuickSuggest.weather.waitForFetches();
+ lazy.UrlbarPrefs.set("weather.featureGate", true);
+ lazy.UrlbarPrefs.set("suggest.weather", true);
+ await fetchPromise;
+ this.info("MockMerinoServer initializing weather, got fetch");
+
+ 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;
+ });
+ }
+
+ /**
+ * Initializes the mock Merino geolocation server.
+ */
+ async initGeolocation() {
+ await this.server.start();
+ this.server.response.body.suggestions = [GEOLOCATION_DATA];
+ }
+
+ #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: "amp",
+ title: "Amp Suggestion",
+ url: "http://example.com/amp",
+ icon: null,
+ impression_url: "http://example.com/amp-impression",
+ click_url: "http://example.com/amp-click",
+ block_id: 1,
+ advertiser: "Amp",
+ iab_category: "22 - Shopping",
+ 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 = Promise.withResolvers();
+ }
+ 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 = Promise.withResolvers();
+ 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..2ba9dce8be
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs
@@ -0,0 +1,915 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable mozilla/valid-lazy */
+/* eslint-disable jsdoc/require-param */
+
+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",
+ RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
+ RemoteSettingsConfig: "resource://gre/modules/RustRemoteSettings.sys.mjs",
+ RemoteSettingsServer:
+ "resource://testing-common/RemoteSettingsServer.sys.mjs",
+ SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
+ SuggestBackendRust:
+ "resource:///modules/urlbar/private/SuggestBackendRust.sys.mjs",
+ Suggestion: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestionProvider: "resource://gre/modules/RustSuggest.sys.mjs",
+ SuggestStore: "resource://gre/modules/RustSuggest.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;
+ },
+});
+
+// TODO bug 1881409: Previously this was an empty object, but the Rust backend
+// seems to persist old config after ingesting an empty config object.
+const DEFAULT_CONFIG = {
+ // Zero means there is no cap, the same as if this wasn't specified at all.
+ show_less_frequently_cap: 0,
+};
+
+// 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",
+];
+
+/**
+ * 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);
+ }
+
+ /**
+ * Sets up local remote settings and Merino servers, registers test
+ * suggestions, and initializes Suggest.
+ *
+ * @param {object} options
+ * Options object
+ * @param {Array} options.remoteSettingsRecords
+ * Array of remote settings records. Each item in this array should be a
+ * realistic remote settings record with some exceptions, e.g.,
+ * `record.attachment`, if defined, should be the attachment itself and not
+ * its metadata. For details see `RemoteSettingsServer.addRecords()`.
+ * @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 Suggest configuration object. This should not be the full remote
+ * settings record; only pass the object that should be set to the nested
+ * `configuration` object inside the record.
+ * @param {Array} options.prefs
+ * An array of Suggest-related prefs to set. This is useful because setting
+ * some prefs, like feature gates, can cause Suggest to sync from remote
+ * settings; this function will set them, wait for sync to finish, and clear
+ * them when the cleanup function is called. Each item in this array should
+ * itself be a two-element array `[prefName, prefValue]` similar to the
+ * `set` array passed to `SpecialPowers.pushPrefEnv()`, except here pref
+ * names are relative to `browser.urlbar`.
+ * @returns {Function}
+ * An async cleanup function. This function is automatically registered as a
+ * cleanup function, so you only need to call it if your test needs to clean
+ * up Suggest before it ends, for example if you have a small number of
+ * tasks that need Suggest and it's not enabled throughout your test. The
+ * cleanup function is idempotent so there's no harm in calling it more than
+ * once. Be sure to `await` it.
+ */
+ async ensureQuickSuggestInit({
+ remoteSettingsRecords = [],
+ merinoSuggestions = null,
+ config = DEFAULT_CONFIG,
+ prefs = [],
+ } = {}) {
+ prefs.push(["quicksuggest.enabled", true]);
+
+ // Set up the local remote settings server.
+ this.#log(
+ "ensureQuickSuggestInit",
+ "Started, preparing remote settings server"
+ );
+ if (!this.#remoteSettingsServer) {
+ this.#remoteSettingsServer = new lazy.RemoteSettingsServer();
+ }
+ await this.#remoteSettingsServer.setRecords({
+ collection: "quicksuggest",
+ records: [
+ ...remoteSettingsRecords,
+ { type: "configuration", configuration: config },
+ ],
+ });
+ this.#log("ensureQuickSuggestInit", "Starting remote settings server");
+ await this.#remoteSettingsServer.start();
+ this.#log("ensureQuickSuggestInit", "Remote settings server started");
+
+ // Get the cached `RemoteSettings` client used by the JS backend and tell it
+ // to ignore signatures and to always force sync. Otherwise it won't sync if
+ // the previous sync was recent enough, which is incompatible with testing.
+ let rs = lazy.RemoteSettings("quicksuggest");
+ let { get, verifySignature } = rs;
+ rs.verifySignature = false;
+ rs.get = opts => get.call(rs, { forceSync: true, ...opts });
+ this.#restoreRemoteSettings = () => {
+ rs.verifySignature = verifySignature;
+ rs.get = get;
+ };
+
+ // Finally, init Suggest and set prefs. Do this after setting up remote
+ // settings because the current backend will immediately try to sync.
+ this.#log(
+ "ensureQuickSuggestInit",
+ "Calling QuickSuggest.init() and setting prefs"
+ );
+ lazy.QuickSuggest.init();
+ for (let [name, value] of prefs) {
+ lazy.UrlbarPrefs.set(name, value);
+ }
+
+ // Tell the Rust backend to use the local remote setting server.
+ await lazy.QuickSuggest.rustBackend._test_setRemoteSettingsConfig(
+ new lazy.RemoteSettingsConfig({
+ collectionName: "quicksuggest",
+ bucketName: "main",
+ serverUrl: this.#remoteSettingsServer.url.toString(),
+ })
+ );
+
+ // Wait for the current backend to finish syncing.
+ await this.forceSync();
+
+ // Set up Merino. This can happen any time relative to Suggest init.
+ if (merinoSuggestions) {
+ this.#log("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.#log("ensureQuickSuggestInit", "Done setting up Merino server");
+ }
+
+ let cleanupCalled = false;
+ let cleanup = async () => {
+ if (!cleanupCalled) {
+ cleanupCalled = true;
+ await this.#uninitQuickSuggest(prefs, !!merinoSuggestions);
+ }
+ };
+ this.registerCleanupFunction?.(cleanup);
+
+ this.#log("ensureQuickSuggestInit", "Done");
+ return cleanup;
+ }
+
+ async #uninitQuickSuggest(prefs, clearDataCollectionEnabled) {
+ this.#log("#uninitQuickSuggest", "Started");
+
+ // Reset prefs, which can cause the current backend to start syncing. Wait
+ // for it to finish.
+ for (let [name] of prefs) {
+ lazy.UrlbarPrefs.clear(name);
+ }
+ await this.forceSync();
+
+ this.#log("#uninitQuickSuggest", "Stopping remote settings server");
+ await this.#remoteSettingsServer.stop();
+ this.#restoreRemoteSettings();
+
+ if (clearDataCollectionEnabled) {
+ lazy.UrlbarPrefs.clear("quicksuggest.dataCollection.enabled");
+ }
+
+ this.#log("#uninitQuickSuggest", "Done");
+ }
+
+ /**
+ * Removes all records from the local remote settings server and adds a new
+ * batch of records.
+ *
+ * @param {Array} records
+ * Array of remote settings records. See `ensureQuickSuggestInit()`.
+ * @param {object} options
+ * Options object.
+ * @param {boolean} options.forceSync
+ * Whether to force Suggest to sync after updating the records.
+ */
+ async setRemoteSettingsRecords(records, { forceSync = true } = {}) {
+ this.#log("setRemoteSettingsRecords", "Started");
+ await this.#remoteSettingsServer.setRecords({
+ collection: "quicksuggest",
+ records,
+ });
+ if (forceSync) {
+ this.#log("setRemoteSettingsRecords", "Forcing sync");
+ await this.forceSync();
+ }
+ this.#log("setRemoteSettingsRecords", "Done");
+ }
+
+ /**
+ * Sets the quick suggest configuration. You should call this again with
+ * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`.
+ *
+ * @param {object} config
+ * The quick suggest configuration object. This should not be the full
+ * remote settings record; only pass the object that should be set to the
+ * `configuration` nested object inside the record.
+ */
+ async setConfig(config) {
+ this.#log("setConfig", "Started");
+ let type = "configuration";
+ this.#remoteSettingsServer.removeRecords({ type });
+ await this.#remoteSettingsServer.addRecords({
+ collection: "quicksuggest",
+ records: [{ type, configuration: config }],
+ });
+ this.#log("setConfig", "Forcing sync");
+ await this.forceSync();
+ this.#log("setConfig", "Done");
+ }
+
+ /**
+ * Forces Suggest to sync with remote settings. This can be used to ensure
+ * Suggest has finished all sync activity.
+ */
+ async forceSync() {
+ this.#log("forceSync", "Started");
+ if (lazy.QuickSuggest.rustBackend.isEnabled) {
+ this.#log("forceSync", "Syncing Rust backend");
+ await lazy.QuickSuggest.rustBackend._test_ingest();
+ this.#log("forceSync", "Done syncing Rust backend");
+ }
+ if (lazy.QuickSuggest.jsBackend.isEnabled) {
+ this.#log("forceSync", "Syncing JS backend");
+ await lazy.QuickSuggest.jsBackend._test_syncAll();
+ this.#log("forceSync", "Done syncing JS backend");
+ }
+ this.#log("forceSync", "Done");
+ }
+
+ /**
+ * 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.QuickSuggest.jsBackend.config;
+ await this.setConfig(config);
+ await callback();
+ await this.setConfig(original);
+ }
+
+ /**
+ * Returns an AMP (sponsored) suggestion suitable for storing in a remote
+ * settings attachment.
+ *
+ * @returns {object}
+ * An AMP suggestion for storing in remote settings.
+ */
+ ampRemoteSettings({
+ keywords = ["amp"],
+ url = "http://example.com/amp",
+ title = "Amp Suggestion",
+ score = 0.3,
+ }) {
+ return {
+ keywords,
+ url,
+ title,
+ score,
+ id: 1,
+ click_url: "http://example.com/amp-click",
+ impression_url: "http://example.com/amp-impression",
+ advertiser: "Amp",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+ };
+ }
+
+ /**
+ * Returns a Wikipedia (non-sponsored) suggestion suitable for storing in a
+ * remote settings attachment.
+ *
+ * @returns {object}
+ * A Wikipedia suggestion for storing in remote settings.
+ */
+ wikipediaRemoteSettings({
+ keywords = ["wikipedia"],
+ url = "http://example.com/wikipedia",
+ title = "Wikipedia Suggestion",
+ score = 0.2,
+ }) {
+ return {
+ keywords,
+ url,
+ title,
+ score,
+ id: 2,
+ click_url: "http://example.com/wikipedia-click",
+ impression_url: "http://example.com/wikipedia-impression",
+ advertiser: "Wikipedia",
+ iab_category: "5 - Education",
+ icon: "1234",
+ };
+ }
+
+ /**
+ * Returns an AMO (addons) suggestion suitable for storing in a remote
+ * settings attachment.
+ *
+ * @returns {object}
+ * An AMO suggestion for storing in remote settings.
+ */
+ amoRemoteSettings({
+ keywords = ["amo"],
+ url = "http://example.com/amo",
+ title = "Amo Suggestion",
+ score = 0.2,
+ }) {
+ return {
+ keywords,
+ url,
+ title,
+ score,
+ guid: "amo-suggestion@example.com",
+ icon: "https://example.com/addon.svg",
+ rating: "4.7",
+ description: "Addon with score",
+ number_of_ratings: 1256,
+ };
+ }
+
+ /**
+ * 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.#log(
+ "assertIsQuickSuggest",
+ `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 = row._elements.get("description");
+ if (isSponsored || isBestMatch) {
+ this.Assert.ok(sponsoredElement, "Result sponsored label element exists");
+ this.Assert.equal(
+ sponsoredElement.textContent,
+ isSponsored ? "Sponsored" : "",
+ "Result sponsored label"
+ );
+ } else {
+ this.Assert.ok(
+ !sponsoredElement,
+ "Result sponsored label element should not exist"
+ );
+ }
+
+ this.Assert.equal(
+ result.payload.helpUrl,
+ lazy.QuickSuggest.HELP_URL,
+ "Result helpURL"
+ );
+
+ this.Assert.ok(
+ row._buttons.get("menu"),
+ "The menu button should be present"
+ );
+
+ 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
+ );
+ }
+
+ /**
+ * 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.#log(
+ "assertTimestampsReplaced",
+ "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.#log("enrollExperiment", "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.#log(
+ "enrollExperiment",
+ "Awaiting update after enrolling in experiment"
+ );
+ await this.waitForScenarioUpdated();
+
+ return async () => {
+ this.#log("enrollExperiment.cleanup", "Awaiting experiment cleanup");
+ await doExperimentCleanup();
+
+ // The same pref updates will be triggered by unenrollment, so wait for
+ // them again.
+ this.#log(
+ "enrollExperiment.cleanup",
+ "Awaiting update after unenrolling in experiment"
+ );
+ await this.waitForScenarioUpdated();
+ };
+ }
+
+ /**
+ * 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.#log(
+ "withLocales",
+ "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.#log("withLocales", "Waiting for intl:requested-locales-changed");
+ await lazy.TestUtils.topicObserved("intl:requested-locales-changed");
+ this.#log("withLocales", "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.#log("withLocales", "Waiting for TOPIC_SEARCH_SERVICE");
+ await Promise.race([
+ lazy.TestUtils.topicObserved(
+ lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
+ (subject, data) => {
+ this.#log(
+ "withLocales",
+ "Observed TOPIC_SEARCH_SERVICE with data: " + data
+ );
+ return data == "engines-reloaded";
+ }
+ ),
+ new Promise(resolve => {
+ lazy.setTimeout(() => {
+ this.#log(
+ "withLocales",
+ "Timed out waiting for TOPIC_SEARCH_SERVICE"
+ );
+ resolve();
+ }, 2000);
+ }),
+ ]);
+
+ this.#log("withLocales", "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;
+ }
+
+ #log(fnName, msg) {
+ this.info?.(`QuickSuggestTestUtils.${fnName} ${msg}`);
+ }
+
+ #remoteSettingsServer;
+ #restoreRemoteSettings;
+}
+
+export var QuickSuggestTestUtils = new _QuickSuggestTestUtils();
diff --git a/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs b/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs
new file mode 100644
index 0000000000..32b42198c3
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs
@@ -0,0 +1,619 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable jsdoc/require-param-description */
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ HttpError: "resource://testing-common/httpd.sys.mjs",
+ HttpServer: "resource://testing-common/httpd.sys.mjs",
+ HTTP_404: "resource://testing-common/httpd.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+});
+
+const SERVER_PREF = "services.settings.server";
+
+/**
+ * A remote settings server. Tested with the desktop and Rust remote settings
+ * clients.
+ */
+export class RemoteSettingsServer {
+ /**
+ * The server must be started by calling `start()`.
+ *
+ * @param {object} options
+ * @param {number} options.logLevel
+ * A `Log.Level` value from `Log.sys.mjs`. `Log.Level.Info` logs basic info
+ * on requests and responses like paths and status codes. Pass
+ * `Log.Level.Debug` to log more info like headers, response bodies, and
+ * added and removed records.
+ */
+ constructor({ logLevel = lazy.Log.Level.Info } = {}) {
+ this.#log = lazy.Log.repository.getLogger("RemoteSettingsServer");
+ this.#log.level = logLevel;
+
+ // Use `DumpAppender` instead of `ConsoleAppender`. The xpcshell and browser
+ // test harnesses buffer console messages and log them later, which makes it
+ // really hard to debug problems. `DumpAppender` logs to stdout, which the
+ // harnesses log immediately.
+ this.#log.addAppender(
+ new lazy.Log.DumpAppender(new lazy.Log.BasicFormatter())
+ );
+ }
+
+ /**
+ * @returns {URL}
+ * The server's URL. Null when the server is stopped.
+ */
+ get url() {
+ return this.#url;
+ }
+
+ /**
+ * Starts the server and sets the `services.settings.server` pref to its
+ * URL. The server's `url` property will be non-null on return.
+ */
+ async start() {
+ this.#log.info("Starting");
+
+ if (this.#url) {
+ this.#log.info("Already started at " + this.#url);
+ return;
+ }
+
+ if (!this.#server) {
+ this.#server = new lazy.HttpServer();
+ this.#server.registerPrefixHandler("/", this);
+ }
+ this.#server.start(-1);
+
+ this.#url = new URL("http://localhost/v1");
+ this.#url.port = this.#server.identity.primaryPort;
+
+ this.#originalServerPrefValue = Services.prefs.getCharPref(
+ SERVER_PREF,
+ null
+ );
+ Services.prefs.setCharPref(SERVER_PREF, this.#url.toString());
+
+ this.#log.info("Server is now started at " + this.#url);
+ }
+
+ /**
+ * Stops the server and clears the `services.settings.server` pref. The
+ * server's `url` property will be null on return.
+ */
+ async stop() {
+ this.#log.info("Stopping");
+
+ if (!this.#url) {
+ this.#log.info("Already stopped");
+ return;
+ }
+
+ await this.#server.stop();
+ this.#url = null;
+
+ if (this.#originalServerPrefValue === null) {
+ Services.prefs.clearUserPref(SERVER_PREF);
+ } else {
+ Services.prefs.setCharPref(SERVER_PREF, this.#originalServerPrefValue);
+ }
+
+ this.#log.info("Server is now stopped");
+ }
+
+ /**
+ * Adds remote settings records to the server. Records may have attachments;
+ * see the param doc below.
+ *
+ * @param {object} options
+ * @param {string} options.bucket
+ * @param {string} options.collection
+ * @param {Array} options.records
+ * Each object in this array should be a realistic remote settings record
+ * with the following exceptions:
+ *
+ * - `record.id` will be generated if it's undefined.
+ * - `record.last_modified` will be set to the `#lastModified` property of
+ * the server if it's undefined.
+ * - `record.attachment`, if defined, should be the attachment itself and
+ * not its metadata. The server will automatically create some dummy
+ * metadata. Currently the only supported attachment type is plain
+ * JSON'able objects that the server will convert to JSON in responses.
+ */
+ async addRecords({ bucket = "main", collection = "test", records }) {
+ this.#log.debug(
+ "Adding records: " +
+ JSON.stringify({ bucket, collection, records }, null, 2)
+ );
+
+ this.#lastModified++;
+
+ let key = this.#recordsKey(bucket, collection);
+ let allRecords = this.#records.get(key);
+ if (!allRecords) {
+ allRecords = [];
+ this.#records.set(key, allRecords);
+ }
+
+ for (let record of records) {
+ let copy = { ...record };
+ if (!copy.hasOwnProperty("id")) {
+ copy.id = String(this.#nextRecordId++);
+ }
+ if (!copy.hasOwnProperty("last_modified")) {
+ copy.last_modified = this.#lastModified;
+ }
+ if (copy.attachment) {
+ await this.#addAttachment({ bucket, collection, record: copy });
+ }
+ allRecords.push(copy);
+ }
+
+ this.#log.debug(
+ "Done adding records. All records are now: " +
+ JSON.stringify([...this.#records.entries()], null, 2)
+ );
+ }
+
+ /**
+ * Marks records as deleted. Deleted records will still be returned in
+ * responses, but they'll have a `deleted = true` property. Their attachments
+ * will be deleted immediately, however.
+ *
+ * @param {object} filter
+ * If null, all records will be marked as deleted. Otherwise only records
+ * that match the filter will be marked as deleted. For a given record, each
+ * value in the filter object will be compared to the value with the same
+ * key in the record. If all values are the same, the record will be
+ * removed. Examples:
+ *
+ * To remove remove records whose `type` key has the value "data":
+ * `{ type: "data" }
+ *
+ * To remove remove records whose `type` key has the value "data" and whose
+ * `last_modified` key has the value 1234:
+ * `{ type: "data", last_modified: 1234 }
+ */
+ removeRecords(filter = null) {
+ this.#log.debug("Removing records: " + JSON.stringify({ filter }));
+
+ this.#lastModified++;
+
+ for (let [recordsKey, records] of this.#records.entries()) {
+ for (let record of records) {
+ if (
+ !filter ||
+ Object.entries(filter).every(
+ ([filterKey, filterValue]) =>
+ record.hasOwnProperty(filterKey) &&
+ record[filterKey] == filterValue
+ )
+ ) {
+ if (record.attachment) {
+ let attachmentKey = `${recordsKey}/${record.attachment.filename}`;
+ this.#attachments.delete(attachmentKey);
+ }
+ record.deleted = true;
+ record.last_modified = this.#lastModified;
+ }
+ }
+ }
+
+ this.#log.debug(
+ "Done removing records. All records are now: " +
+ JSON.stringify([...this.#records.entries()], null, 2)
+ );
+ }
+
+ /**
+ * Removes all existing records and adds the given records to the server.
+ *
+ * @param {object} options
+ * @param {string} options.bucket
+ * @param {string} options.collection
+ * @param {Array} options.records
+ * See `addRecords()`.
+ */
+ async setRecords({ bucket = "main", collection = "test", records }) {
+ this.#log.debug("Setting records");
+
+ this.removeRecords();
+ await this.addRecords({ bucket, collection, records });
+
+ this.#log.debug("Done setting records");
+ }
+
+ /**
+ * `nsIHttpRequestHandler` callback from the backing server. Handles a
+ * request.
+ *
+ * @param {nsIHttpRequest} request
+ * @param {nsIHttpResponse} response
+ */
+ handle(request, response) {
+ this.#logRequest(request);
+
+ // Get the route that matches the request path.
+ let { match, route } = this.#getRoute(request.path) || {};
+ if (!route) {
+ this.#prepareError({ request, response, error: lazy.HTTP_404 });
+ return;
+ }
+
+ let respInfo = route.response(match, request, response);
+ if (respInfo instanceof lazy.HttpError) {
+ this.#prepareError({ request, response, error: respInfo });
+ } else {
+ this.#prepareResponse({ ...respInfo, request, response });
+ }
+ }
+
+ /**
+ * @returns {Array}
+ * The routes handled by the server. Each item in this array is an object
+ * with the following properties that describes one or more paths and the
+ * response that should be sent when a request is made on those paths:
+ *
+ * {string} spec
+ * A path spec. This is required unless `specs` is defined. To determine
+ * which route should be used for a given request, the server will check
+ * each route's spec(s) until it finds the first that matches the
+ * request's path. A spec is just a path whose components can be variables
+ * that start with "$". When a spec with variables matches a request path,
+ * the `match` object passed to the route's `response` function will map
+ * from variable names to the corresponding components in the path.
+ * {Array} specs
+ * An array of path spec strings. Use this instead of `spec` if the route
+ * handles more than one.
+ * {function} response
+ * A function that will be called when the route matches a request. It is
+ * called as: `response(match, request, response)`
+ *
+ * {object} match
+ * An object mapping variable names in the spec to their matched
+ * components in the path. See `#match()` for details.
+ * {nsIHttpRequest} request
+ * {nsIHttpResponse} response
+ *
+ * The function must return one of the following:
+ *
+ * {object}
+ * An object that describes the response with the following properties:
+ * {object} body
+ * A plain JSON'able object. The server will convert this to JSON and
+ * set it to the response body.
+ * {HttpError}
+ * An `HttpError` instance defined in `httpd.sys.mjs`.
+ */
+ get #routes() {
+ return [
+ {
+ spec: "/v1",
+ response: () => ({
+ body: {
+ capabilities: {
+ attachments: {
+ base_url: this.#url.toString(),
+ },
+ },
+ },
+ }),
+ },
+
+ {
+ spec: "/v1/buckets/monitor/collections/changes/changeset",
+ response: () => ({
+ body: {
+ timestamp: this.#lastModified,
+ changes: [
+ {
+ last_modified: this.#lastModified,
+ },
+ ],
+ },
+ }),
+ },
+
+ {
+ spec: "/v1/buckets/$bucket/collections/$collection/changeset",
+ response: ({ bucket, collection }) => {
+ let records = this.#getRecords(bucket, collection);
+ return !records
+ ? lazy.HTTP_404
+ : {
+ body: {
+ metadata: null,
+ timestamp: this.#lastModified,
+ changes: records,
+ },
+ };
+ },
+ },
+
+ {
+ spec: "/v1/buckets/$bucket/collections/$collection/records",
+ response: ({ bucket, collection }) => {
+ let records = this.#getRecords(bucket, collection);
+ return !records
+ ? lazy.HTTP_404
+ : {
+ body: {
+ data: records,
+ },
+ };
+ },
+ },
+
+ {
+ specs: [
+ // The Rust remote settings client doesn't include "v1" in attachment
+ // URLs, but the JS client does.
+ "/attachments/$bucket/$collection/$filename",
+ "/v1/attachments/$bucket/$collection/$filename",
+ ],
+ response: ({ bucket, collection, filename }) => {
+ return {
+ body: this.#getAttachment(bucket, collection, filename),
+ };
+ },
+ },
+ ];
+ }
+
+ /**
+ * @returns {object}
+ * Default response headers.
+ */
+ get #responseHeaders() {
+ return {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Expose-Headers":
+ "Retry-After, Content-Length, Alert, Backoff",
+ Server: "waitress",
+ Etag: `"${this.#lastModified}"`,
+ };
+ }
+
+ /**
+ * Returns the route that matches a request path.
+ *
+ * @param {string} path
+ * A request path.
+ * @returns {object}
+ * If no route matches the path, returns an empty object. Otherwise returns
+ * an object with the following properties:
+ *
+ * {object} match
+ * An object describing the matched variables in the route spec. See
+ * `#match()` for details.
+ * {object} route
+ * The matched route. See `#routes` for details.
+ */
+ #getRoute(path) {
+ for (let route of this.#routes) {
+ let specs = route.specs || [route.spec];
+ for (let spec of specs) {
+ let match = this.#match(path, spec);
+ if (match) {
+ return { match, route };
+ }
+ }
+ }
+ return {};
+ }
+
+ /**
+ * Matches a request path to a route spec.
+ *
+ * @param {string} path
+ * A request path.
+ * @param {string} spec
+ * A route spec. See `#routes` for details.
+ * @returns {object|null}
+ * If the spec doesn't match the path, returns null. Otherwise returns an
+ * object mapping variable names in the spec to their matched components in
+ * the path. Example:
+ *
+ * path : "/main/myfeature/foo"
+ * spec : "/$bucket/$collection/foo"
+ * returns: `{ bucket: "main", collection: "myfeature" }`
+ */
+ #match(path, spec) {
+ let pathParts = path.split("/");
+ let specParts = spec.split("/");
+
+ if (pathParts.length != specParts.length) {
+ // If the path has only one more part than the spec and its last part is
+ // empty, then the path ends in a trailing slash but the spec does not.
+ // Consider that a match. Otherwise return null for no match.
+ if (
+ pathParts[pathParts.length - 1] ||
+ pathParts.length != specParts.length + 1
+ ) {
+ return null;
+ }
+ pathParts.pop();
+ }
+
+ let match = {};
+ for (let i = 0; i < pathParts.length; i++) {
+ let pathPart = pathParts[i];
+ let specPart = specParts[i];
+ if (specPart.startsWith("$")) {
+ match[specPart.substring(1)] = pathPart;
+ } else if (pathPart != specPart) {
+ return null;
+ }
+ }
+
+ return match;
+ }
+
+ #getRecords(bucket, collection) {
+ return this.#records.get(this.#recordsKey(bucket, collection));
+ }
+
+ #recordsKey(bucket, collection) {
+ return `${bucket}/${collection}`;
+ }
+
+ /**
+ * Registers an attachment for a record.
+ *
+ * @param {object} options
+ * @param {string} options.bucket
+ * @param {string} options.collection
+ * @param {object} options.record
+ * The record should have an `attachment` property as described in
+ * `addRecords()`.
+ */
+ async #addAttachment({ bucket, collection, record }) {
+ let { attachment } = record;
+ let filename = record.id;
+
+ this.#attachments.set(
+ this.#attachmentsKey(bucket, collection, filename),
+ attachment
+ );
+
+ let encoder = new TextEncoder();
+ let bytes = encoder.encode(JSON.stringify(attachment));
+
+ let hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
+ let hashBytes = new Uint8Array(hashBuffer);
+ let toHex = b => b.toString(16).padStart(2, "0");
+ let hash = Array.from(hashBytes, toHex).join("");
+
+ // Replace `record.attachment` with appropriate metadata in order to conform
+ // with the remote settings API.
+ record.attachment = {
+ hash,
+ filename,
+ mimetype: "application/json; charset=UTF-8",
+ size: bytes.length,
+ location: `attachments/${bucket}/${collection}/${filename}`,
+ };
+ }
+
+ #attachmentsKey(bucket, collection, filename) {
+ return `${bucket}/${collection}/${filename}`;
+ }
+
+ #getAttachment(bucket, collection, filename) {
+ return this.#attachments.get(
+ this.#attachmentsKey(bucket, collection, filename)
+ );
+ }
+
+ /**
+ * Prepares an HTTP response.
+ *
+ * @param {object} options
+ * @param {nsIHttpRequest} options.request
+ * @param {nsIHttpResponse} options.response
+ * @param {object|null} options.body
+ * Currently only JSON'able objects are supported. They will be converted to
+ * JSON in the response.
+ * @param {integer} options.status
+ * @param {string} options.statusText
+ */
+ #prepareResponse({
+ request,
+ response,
+ body = null,
+ status = 200,
+ statusText = "OK",
+ }) {
+ let headers = { ...this.#responseHeaders };
+ if (body) {
+ headers["Content-Type"] = "application/json; charset=UTF-8";
+ }
+
+ this.#logResponse({ request, status, statusText, headers, body });
+
+ for (let [name, value] of Object.entries(headers)) {
+ response.setHeader(name, value, false);
+ }
+ if (body) {
+ response.write(JSON.stringify(body));
+ }
+ response.setStatusLine(request.httpVersion, status, statusText);
+ }
+
+ /**
+ * Prepares an HTTP error response.
+ *
+ * @param {object} options
+ * @param {nsIHttpRequest} options.request
+ * @param {nsIHttpResponse} options.response
+ * @param {HttpError} options.error
+ * An `HttpError` instance defined in `httpd.sys.mjs`.
+ */
+ #prepareError({ request, response, error }) {
+ this.#prepareResponse({
+ request,
+ response,
+ status: error.code,
+ statusText: error.description,
+ });
+ }
+
+ /**
+ * Logs a request.
+ *
+ * @param {nsIHttpRequest} request
+ */
+ #logRequest(request) {
+ let pathAndQuery = request.path;
+ if (request.queryString) {
+ pathAndQuery += "?" + request.queryString;
+ }
+ this.#log.info(
+ `< HTTP ${request.httpVersion} ${request.method} ${pathAndQuery}`
+ );
+ for (let name of request.headers) {
+ this.#log.debug(`${name}: ${request.getHeader(name.toString())}`);
+ }
+ }
+
+ /**
+ * Logs a response.
+ *
+ * @param {object} options
+ * @param {nsIHttpRequest} options.request
+ * The associated request.
+ * @param {integer} options.status
+ * The HTTP status code of the response.
+ * @param {string} options.statusText
+ * The description of the status code.
+ * @param {object} options.headers
+ * An object mapping from response header names to values.
+ * @param {object} options.body
+ * The response body, if any.
+ */
+ #logResponse({ request, status, statusText, headers, body }) {
+ this.#log.info(`> ${status} ${request.path}`);
+ for (let [name, value] of Object.entries(headers)) {
+ this.#log.debug(`${name}: ${value}`);
+ }
+ if (body) {
+ this.#log.debug("Response body: " + JSON.stringify(body, null, 2));
+ }
+ }
+
+ // records key (see `#recordsKey()`) -> array of record objects
+ #records = new Map();
+
+ // attachments key (see `#attachmentsKey()`) -> attachment object
+ #attachments = new Map();
+
+ #log;
+ #server;
+ #originalServerPrefValue;
+ #url = null;
+ #lastModified = 1368273600000;
+ #nextRecordId = 1;
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser.toml b/browser/components/urlbar/tests/quicksuggest/browser/browser.toml
new file mode 100644
index 0000000000..a77d26c2a6
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser.toml
@@ -0,0 +1,68 @@
+# 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",
+]
+prefs = ["browser.bookmarks.testing.skipDefaultBookmarksImport=true"]
+
+["browser_quicksuggest.js"]
+
+["browser_quicksuggest_addons.js"]
+
+["browser_quicksuggest_block.js"]
+
+["browser_quicksuggest_configuration.js"]
+
+["browser_quicksuggest_indexes.js"]
+
+["browser_quicksuggest_mdn.js"]
+
+["browser_quicksuggest_merinoSessions.js"]
+
+["browser_quicksuggest_onboardingDialog.js"]
+skip-if = ["os == 'linux' && bits == 64"] # Bug 1773830
+
+["browser_quicksuggest_pocket.js"]
+tags = "search-telemetry"
+
+["browser_quicksuggest_yelp.js"]
+
+["browser_telemetry_dynamicWikipedia.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["browser_telemetry_gleanEmptyStrings.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["browser_telemetry_impressionEdgeCases.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["browser_telemetry_navigationalSuggestions.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["browser_telemetry_nonsponsored.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["browser_telemetry_other.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["browser_telemetry_sponsored.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["browser_telemetry_weather.js"]
+tags = "search-telemetry"
+skip-if = ["true"] # Bug 1880214
+
+["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..130afe8c53
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest.js
@@ -0,0 +1,166 @@
+/* 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",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+ },
+ {
+ 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: "Wikipedia",
+ iab_category: "5 - Education",
+ icon: "1234",
+ },
+];
+
+add_setup(async function () {
+ await PlacesUtils.history.clear();
+ await PlacesUtils.bookmarks.eraseEverything();
+ await UrlbarTestUtils.formHistory.clear();
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ });
+});
+
+// Tests a sponsored result and keyword highlighting.
+add_tasks_with_rust(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_tasks_with_rust(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);
+});
+
+// Tests sponsored priority feature.
+add_tasks_with_rust(async function sponsoredPriority() {
+ const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({
+ quickSuggestSponsoredPriority: true,
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "fra",
+ });
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ index: 1,
+ isSponsored: true,
+ isBestMatch: 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."
+ );
+
+ // Group label.
+ let before = window.getComputedStyle(row, "::before");
+ Assert.equal(before.content, "attr(label)", "::before.content is enabled");
+ Assert.equal(
+ row.getAttribute("label"),
+ "Top pick",
+ "Row has 'Top pick' group label"
+ );
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await cleanUpNimbus();
+});
+
+// Tests sponsored priority feature does not affect to non-sponsored suggestion.
+add_tasks_with_rust(
+ async function sponsoredPriorityButNotSponsoredSuggestion() {
+ const cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({
+ quickSuggestSponsoredPriority: true,
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "nonspon",
+ });
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ index: 1,
+ isSponsored: false,
+ url: `${TEST_URL}?q=nonsponsored`,
+ });
+
+ let row = await UrlbarTestUtils.waitForAutocompleteResultAt(window, 1);
+ let before = window.getComputedStyle(row, "::before");
+ Assert.equal(before.content, "attr(label)", "::before.content is enabled");
+ Assert.equal(
+ row.getAttribute("label"),
+ "Firefox Suggest",
+ "Row has general group label for quick suggest"
+ );
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await cleanUpNimbus();
+ }
+);
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..b09345aa54
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_addons.js
@@ -0,0 +1,443 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for addon suggestions.
+
+// The expected index of the addon suggestion.
+const EXPECTED_RESULT_INDEX = 1;
+
+// Allow more time for TV runs.
+requestLongerTimeout(5);
+
+// TODO: Firefox no longer uses `rating` and `number_of_ratings` but they are
+// still present in Merino and RS suggestions, so they are included here for
+// greater accuracy. We should remove them from Merino, RS, and tests.
+const 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: [
+ // Disable search suggestions so we don't hit the network.
+ ["browser.search.suggest.enabled", false],
+ ],
+ });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ merinoSuggestions: TEST_MERINO_SUGGESTIONS,
+ });
+});
+
+add_task(async function basic() {
+ 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-favicon");
+ Assert.equal(icon.src, merinoSuggestion.icon);
+ const url = row.querySelector(".urlbarView-url");
+ const expectedUrl = makeExpectedUrl(merinoSuggestion.url);
+ const displayUrl = expectedUrl.replace(/^https:\/\//, "");
+ Assert.equal(url.textContent, displayUrl);
+ const title = row.querySelector(".urlbarView-title");
+ Assert.equal(title.textContent, merinoSuggestion.title);
+ const description = row.querySelector(".urlbarView-row-body-description");
+ Assert.equal(description.textContent, merinoSuggestion.description);
+ const bottom = row.querySelector(".urlbarView-row-body-bottom");
+ Assert.equal(bottom.textContent, "Recommended");
+ Assert.ok(
+ BrowserTestUtils.isVisible(
+ row.querySelector(".urlbarView-title-separator")
+ ),
+ "The title separator should be visible"
+ );
+
+ Assert.equal(result.suggestedIndex, 1);
+
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ expectedUrl
+ );
+ EventUtils.synthesizeMouseAtCenter(row, {});
+ await onLoad;
+ Assert.ok(true, "Expected page is loaded");
+
+ await PlacesUtils.history.clear();
+ }
+});
+
+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.showLessFrequentlyCount", 0]],
+ });
+
+ await QuickSuggestTestUtils.setConfig({
+ show_less_frequently_cap: 3,
+ });
+
+ 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);
+
+ // The cap will be reached this time. Keep the view open so we can make sure
+ // the command has been removed from the menu before it closes.
+ await doShowLessFrequently({
+ keepViewOpen: true,
+ input: "aaa b",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: true,
+ },
+ });
+ Assert.equal(UrlbarPrefs.get("addons.showLessFrequentlyCount"), 3);
+
+ // Make sure the command has been removed.
+ let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({
+ window,
+ command: "show_less_frequently",
+ resultIndex: EXPECTED_RESULT_INDEX,
+ openByMouse: true,
+ });
+ Assert.ok(!menuitem, "Menuitem should be absent before closing the view");
+ gURLBar.view.resultMenu.hidePopup(true);
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ 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 QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG);
+ await SpecialPowers.popPrefEnv();
+});
+
+// Tests the "Not interested" result menu dismissal command.
+add_task(async function resultMenu_notInterested() {
+ await doDismissTest("not_interested", true);
+});
+
+// Tests the "Not relevant" result menu dismissal command.
+add_task(async function notRelevant() {
+ await doDismissTest("not_relevant", false);
+});
+
+// Tests the row/group label.
+add_task(async function rowLabel() {
+ 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"), "Firefox extension");
+
+ await UrlbarTestUtils.promisePopupClose(window);
+});
+
+async function doShowLessFrequently({ input, expected, keepViewOpen = false }) {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: input,
+ });
+
+ if (!expected.isSuggestionShown) {
+ Assert.ok(
+ !(await getAddonResultDetails()),
+ "Addons suggestion should be absent"
+ );
+ return;
+ }
+
+ const details = await getAddonResultDetails();
+ Assert.ok(
+ details,
+ `Addons suggestion should be present at expected index after ${input} search`
+ );
+
+ // Click the command.
+ try {
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ "show_less_frequently",
+ {
+ resultIndex: EXPECTED_RESULT_INDEX,
+ }
+ );
+ 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"
+ );
+ }
+
+ if (!keepViewOpen) {
+ await UrlbarTestUtils.promisePopupClose(window);
+ }
+}
+
+async function doDismissTest(command, allDismissed) {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "123",
+ });
+
+ const resultCount = UrlbarTestUtils.getResultCount(window);
+ let details = await getAddonResultDetails();
+ Assert.ok(details, "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: EXPECTED_RESULT_INDEX, openByMouse: true }
+ );
+
+ Assert.equal(
+ UrlbarPrefs.get("suggest.addons"),
+ !allDismissed,
+ "suggest.addons should be true iff all suggestions weren't dismissed"
+ );
+ Assert.equal(
+ await QuickSuggest.blockedSuggestions.has(
+ details.result.payload.originalUrl
+ ),
+ !allDismissed,
+ "Suggestion URL should be blocked iff all suggestions weren't dismissed"
+ );
+
+ // 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,
+ EXPECTED_RESULT_INDEX
+ );
+ 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"
+ );
+
+ // Check tip title.
+ let title = details.element.row.querySelector(".urlbarView-title");
+ let titleL10nId = title.dataset.l10nId;
+ if (allDismissed) {
+ Assert.equal(titleL10nId, "firefox-suggest-dismissal-acknowledgment-all");
+ } else {
+ Assert.equal(titleL10nId, "firefox-suggest-dismissal-acknowledgment-one");
+ }
+
+ // Get the dismissal acknowledgment's "Got it" button and click it.
+ let gotItButton = UrlbarTestUtils.getButtonForResultIndex(
+ window,
+ 0,
+ EXPECTED_RESULT_INDEX
+ );
+ 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 &&
+ !isAddonResult(details.result),
+ "Tip result and addon result should not be present"
+ );
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ UrlbarPrefs.clear("suggest.addons");
+ await QuickSuggest.blockedSuggestions.clear();
+}
+
+function makeExpectedUrl(originalUrl) {
+ let url = new URL(originalUrl);
+ url.searchParams.set("utm_medium", "firefox-desktop");
+ url.searchParams.set("utm_source", "firefox-suggest");
+ return url.href;
+}
+
+async function getAddonResultDetails() {
+ for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
+ const details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ if (isAddonResult(details.result)) {
+ return details;
+ }
+ }
+ return null;
+}
+
+function isAddonResult(result) {
+ return ["AddonSuggestions", "amo"].includes(result.payload.provider);
+}
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..c400cf72f6
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_block.js
@@ -0,0 +1,252 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests quick suggest dismissals ("blocks").
+
+"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",
+ icon: "1234",
+ },
+ {
+ 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: "Wikipedia",
+ iab_category: "5 - Education",
+ icon: "1234",
+ },
+];
+
+add_setup(async function () {
+ 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({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ });
+});
+
+// Picks the dismiss command in the result menu.
+add_tasks_with_rust(async function basic() {
+ await doBasicBlockTest({
+ block: async () => {
+ await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", {
+ resultIndex: 1,
+ });
+ },
+ });
+});
+
+// Uses the key shortcut to block a suggestion.
+add_tasks_with_rust(async function basic_keyShortcut() {
+ await doBasicBlockTest({
+ block: () => {
+ // Arrow down once to select the row.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true });
+ },
+ });
+});
+
+async function doBasicBlockTest({ block }) {
+ for (let result of REMOTE_SETTINGS_RESULTS) {
+ info("Doing basic block test with result: " + JSON.stringify({ result }));
+ await doOneBasicBlockTest({ result, block });
+ }
+}
+
+async function doOneBasicBlockTest({ result, block }) {
+ let index = 2;
+ let suggested_index = -1;
+ let suggested_index_relative_to_group = true;
+ let match_type = "firefox-suggest";
+ let isSponsored = result.iab_category != "5 - Education";
+ let expectedBlockId =
+ UrlbarPrefs.get("quicksuggest.rustEnabled") && !isSponsored
+ ? null
+ : result.id;
+
+ let pingsSubmitted = 0;
+ GleanPings.quickSuggest.testBeforeNextSubmit(() => {
+ pingsSubmitted++;
+ // First ping's an impression.
+ Assert.equal(
+ Glean.quickSuggest.pingType.testGetValue(),
+ CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION
+ );
+ Assert.equal(Glean.quickSuggest.matchType.testGetValue(), match_type);
+ Assert.equal(Glean.quickSuggest.blockId.testGetValue(), expectedBlockId);
+ Assert.equal(Glean.quickSuggest.isClicked.testGetValue(), false);
+ Assert.equal(Glean.quickSuggest.position.testGetValue(), index);
+ Assert.equal(
+ Glean.quickSuggest.suggestedIndex.testGetValue(),
+ suggested_index
+ );
+ Assert.equal(
+ Glean.quickSuggest.suggestedIndexRelativeToGroup.testGetValue(),
+ suggested_index_relative_to_group
+ );
+ Assert.equal(Glean.quickSuggest.position.testGetValue(), index);
+ GleanPings.quickSuggest.testBeforeNextSubmit(() => {
+ pingsSubmitted++;
+ // Second ping's a block.
+ Assert.equal(
+ Glean.quickSuggest.pingType.testGetValue(),
+ CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK
+ );
+ Assert.equal(Glean.quickSuggest.matchType.testGetValue(), match_type);
+ Assert.equal(Glean.quickSuggest.blockId.testGetValue(), expectedBlockId);
+ Assert.equal(
+ Glean.quickSuggest.iabCategory.testGetValue(),
+ result.iab_category
+ );
+ Assert.equal(Glean.quickSuggest.position.testGetValue(), index);
+ });
+ });
+
+ // 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)"
+ );
+
+ await QuickSuggestTestUtils.assertIsQuickSuggest({
+ window,
+ 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 Glean.
+ Assert.equal(pingsSubmitted, 2, "Both Glean pings submitted.");
+
+ // Check telemetry scalars.
+ 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;
+ }
+ QuickSuggestTestUtils.assertScalars(scalars);
+
+ // Check the engagement event.
+ QuickSuggestTestUtils.assertEvents([
+ {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "block",
+ extra: {
+ match_type,
+ position: String(index),
+ suggestion_type: isSponsored ? "sponsored" : "nonsponsored",
+ },
+ },
+ ]);
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await QuickSuggest.blockedSuggestions.clear();
+}
+
+// Blocks multiple suggestions one after the other.
+add_tasks_with_rust(async function blockMultiple() {
+ 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,
+ originalUrl: url,
+ isSponsored: keywords[0] == "sponsored",
+ });
+
+ // Block it.
+ await UrlbarTestUtils.openResultMenuAndPressAccesskey(window, "D", {
+ resultIndex: 1,
+ });
+ 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();
+});
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..d9a4345898
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_configuration.js
@@ -0,0 +1,2099 @@
+/* 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: {
+ merinoEndpointURL: "http://example.com/test_merino_config",
+ merinoClientVariants: "test-client-variants",
+ merinoProviders: "test-providers",
+ },
+ callback: () => {
+ 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..713df1ec02
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_indexes.js
@@ -0,0 +1,410 @@
+/* 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 = [
+ QuickSuggestTestUtils.ampRemoteSettings({
+ keywords: [SPONSORED_SEARCH_STRING],
+ }),
+ QuickSuggestTestUtils.wikipediaRemoteSettings({
+ keywords: [NON_SPONSORED_SEARCH_STRING],
+ }),
+];
+
+// Trying to avoid timeouts.
+requestLongerTimeout(3);
+
+add_setup(async function () {
+ 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({
+ remoteSettingsRecords: [
+ {
+ 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
+ ? REMOTE_SETTINGS_RESULTS[0].url
+ : REMOTE_SETTINGS_RESULTS[1].url,
+ });
+
+ 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_mdn.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js
new file mode 100644
index 0000000000..b7da7533c4
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_mdn.js
@@ -0,0 +1,230 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for mdn suggestions.
+
+const REMOTE_SETTINGS_DATA = [
+ {
+ type: "mdn-suggestions",
+ attachment: [
+ {
+ url: "https://example.com/array-filter",
+ title: "Array.prototype.filter()",
+ description:
+ "The filter() method creates a shallow copy of a portion of a given array, filtered down to just the elements from the given array that pass the test implemented by the provided function.",
+ keywords: ["array"],
+ score: 0.24,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_DATA,
+ });
+});
+
+add_tasks_with_rust(async function basic() {
+ const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0];
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: suggestion.keywords[0],
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element, result } = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ 1
+ );
+ Assert.equal(
+ result.providerName,
+ UrlbarProviderQuickSuggest.name,
+ "The result should be from the expected provider"
+ );
+ Assert.equal(
+ result.payload.provider,
+ UrlbarPrefs.get("quickSuggestRustEnabled") ? "Mdn" : "MDNSuggestions"
+ );
+
+ const onLoad = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ result.payload.url
+ );
+ EventUtils.synthesizeMouseAtCenter(element.row, {});
+ await onLoad;
+ Assert.ok(true, "Expected page is loaded");
+
+ await PlacesUtils.history.clear();
+});
+
+// Tests the row/group label.
+add_tasks_with_rust(async function rowLabel() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: REMOTE_SETTINGS_DATA[0].attachment[0].keywords[0],
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ const row = element.row;
+ Assert.equal(row.getAttribute("label"), "Recommended resource");
+
+ await UrlbarTestUtils.promisePopupClose(window);
+});
+
+add_tasks_with_rust(async function disable() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.mdn.featureGate", false]],
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "array",
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 1);
+
+ const { result } = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
+ Assert.equal(result.providerName, "HeuristicFallback");
+
+ await SpecialPowers.popPrefEnv();
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// Tests the "Not interested" result menu dismissal command.
+add_tasks_with_rust(async function resultMenu_notInterested() {
+ await doDismissTest("not_interested");
+
+ Assert.equal(UrlbarPrefs.get("suggest.mdn"), false);
+ const exists = await QuickSuggest.blockedSuggestions.has(
+ REMOTE_SETTINGS_DATA[0].attachment[0].url
+ );
+ Assert.ok(!exists);
+
+ // Re-enable suggestions and wait until MDNSuggestions syncs them from
+ // remote settings again.
+ UrlbarPrefs.set("suggest.mdn", true);
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// Tests the "Not relevant" result menu dismissal command.
+add_tasks_with_rust(async function notRelevant() {
+ await doDismissTest("not_relevant");
+
+ Assert.equal(UrlbarPrefs.get("suggest.mdn"), true);
+ const exists = await QuickSuggest.blockedSuggestions.has(
+ REMOTE_SETTINGS_DATA[0].attachment[0].url
+ );
+ Assert.ok(exists);
+
+ await QuickSuggest.blockedSuggestions.clear();
+});
+
+async function doDismissTest(command) {
+ const keyword = REMOTE_SETTINGS_DATA[0].attachment[0].keywords[0];
+ // Do a search.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: keyword,
+ });
+
+ // Check the result.
+ const resultCount = 2;
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ resultCount,
+ "There should be two results"
+ );
+
+ const resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(
+ details.result.providerName,
+ UrlbarProviderQuickSuggest.name,
+ "The result should be from the expected provider"
+ );
+ Assert.equal(
+ details.result.payload.telemetryType,
+ "mdn",
+ "The result should be a MDN result"
+ );
+
+ // Click the command.
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ ["[data-l10n-id=firefox-suggest-command-dont-show-mdn]", command],
+ { resultIndex, openByMouse: true }
+ );
+
+ // 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.
+ const 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.telemetryType !== "mdn",
+ "Tip result and suggestion should not be present"
+ );
+ }
+
+ gURLBar.handleRevert();
+
+ // Do the search again.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: keyword,
+ });
+ 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.telemetryType !== "mdn",
+ "Tip result and suggestion should not be present"
+ );
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+}
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..eab63f4c9e
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_merinoSessions.js
@@ -0,0 +1,138 @@
+/* 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.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..6256a5aec2
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_onboardingDialog.js
@@ -0,0 +1,1569 @@
+/* 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();
+
+ // Ensure the test remote settings server is set up. This test doesn't trigger
+ // any suggestions but it enables Suggest, which will attempt to sync from
+ // remote settings.
+ await QuickSuggestTestUtils.ensureQuickSuggestInit();
+});
+
+// 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"
+ );
+ },
+ });
+});
+
+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",
+ "onboardingDialog",
+ "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",
+ "onboardingDialog",
+ "onboardingAccept",
+ ],
+ acceptFocusOrder: [
+ "onboardingAccept",
+ "onboardingLearnMore",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingDialog",
+ "onboardingAccept",
+ ],
+ rejectFocusOrder: [
+ "onboardingReject",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingDialog",
+ "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",
+ "onboardingDialog",
+ "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",
+ "onboardingDialog",
+ "onboardingLearnMore",
+ ],
+ acceptFocusOrder: [
+ "onboardingAccept",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingDialog",
+ "onboardingLearnMore",
+ "onboardingAccept",
+ ],
+ rejectFocusOrder: [
+ "onboardingReject",
+ "onboardingSubmit",
+ "onboardingSkipLink",
+ "onboardingDialog",
+ "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",
+ "onboardingDialog",
+ "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.isVisible(introductionSection));
+ Assert.ok(BrowserTestUtils.isHidden(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.isHidden(introductionSection) &&
+ BrowserTestUtils.isVisible(mainSection)
+ );
+ } else {
+ info("Check the section visibility");
+ Assert.ok(BrowserTestUtils.isHidden(introductionSection));
+ Assert.ok(BrowserTestUtils.isVisible(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.isVisible(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.isVisible(element));
+ } else {
+ if (!element) {
+ Assert.ok(true);
+ return;
+ }
+ Assert.ok(BrowserTestUtils.isHidden(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.isVisible(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.isVisible(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.isHidden(introductionSection) &&
+ BrowserTestUtils.isVisible(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.isVisible(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.isVisible(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.isHidden(introductionSection) &&
+ BrowserTestUtils.isVisible(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_quicksuggest_pocket.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js
new file mode 100644
index 0000000000..0064b6a297
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_pocket.js
@@ -0,0 +1,435 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Browser tests for Pocket suggestions.
+//
+// TODO: Make this work with Rust enabled. Right now, running this test with
+// Rust hits the following error on ingest, which prevents ingest from finishing
+// successfully:
+//
+// 0:03.17 INFO Console message: [JavaScript Error: "1698289045697 urlbar ERROR QuickSuggest.SuggestBackendRust :: Ingest error: Error executing SQL: FOREIGN KEY constraint failed" {file: "resource://gre/modules/Log.sys.mjs" line: 722}]
+
+// The expected index of the Pocket suggestion.
+const EXPECTED_RESULT_INDEX = 1;
+
+const REMOTE_SETTINGS_DATA = [
+ {
+ type: "pocket-suggestions",
+ attachment: [
+ {
+ url: "https://example.com/pocket-suggestion",
+ title: "Pocket Suggestion",
+ description: "Pocket description",
+ lowConfidenceKeywords: ["pocket suggestion"],
+ highConfidenceKeywords: ["high"],
+ score: 0.25,
+ },
+ ],
+ },
+];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable search suggestions so we don't hit the network.
+ ["browser.search.suggest.enabled", false],
+ ],
+ });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_DATA,
+ prefs: [
+ ["suggest.quicksuggest.nonsponsored", true],
+ ["pocket.featureGate", true],
+ ],
+ });
+});
+
+add_task(async function basic() {
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ // Do a search.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "pocket suggestion",
+ });
+
+ // Check the result.
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ 2,
+ "There should be two results"
+ );
+
+ let { element, result } = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ 1
+ );
+ Assert.equal(
+ result.providerName,
+ UrlbarProviderQuickSuggest.name,
+ "The result should be from the expected provider"
+ );
+ Assert.equal(
+ result.payload.telemetryType,
+ "pocket",
+ "The result should be a Pocket result"
+ );
+
+ // Click it.
+ const onLoad = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ EventUtils.synthesizeMouseAtCenter(element.row, {});
+ await onLoad;
+ // Append utm parameters.
+ let url = new URL(REMOTE_SETTINGS_DATA[0].attachment[0].url);
+ url.searchParams.set("utm_medium", "firefox-desktop");
+ url.searchParams.set("utm_source", "firefox-suggest");
+ url.searchParams.set(
+ "utm_campaign",
+ "pocket-collections-in-the-address-bar"
+ );
+ url.searchParams.set("utm_content", "treatment");
+
+ Assert.equal(gBrowser.currentURI.spec, url.href, "Expected page loaded");
+ });
+});
+
+// Tests the "Show less frequently" command.
+add_task(async function resultMenu_showLessFrequently() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.urlbar.pocket.featureGate", true],
+ ["browser.urlbar.pocket.showLessFrequentlyCount", 0],
+ ],
+ });
+ await QuickSuggestTestUtils.forceSync();
+
+ await QuickSuggestTestUtils.setConfig({
+ show_less_frequently_cap: 3,
+ });
+
+ await doShowLessFrequently({
+ input: "pocket s",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: true,
+ },
+ });
+ Assert.equal(UrlbarPrefs.get("pocket.showLessFrequentlyCount"), 1);
+
+ await doShowLessFrequently({
+ input: "pocket s",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: true,
+ },
+ });
+ Assert.equal(UrlbarPrefs.get("pocket.showLessFrequentlyCount"), 2);
+
+ // The cap will be reached this time. Keep the view open so we can make sure
+ // the command has been removed from the menu before it closes.
+ await doShowLessFrequently({
+ keepViewOpen: true,
+ input: "pocket s",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: true,
+ },
+ });
+ Assert.equal(UrlbarPrefs.get("pocket.showLessFrequentlyCount"), 3);
+
+ // Make sure the command has been removed.
+ let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({
+ window,
+ command: "show_less_frequently",
+ resultIndex: EXPECTED_RESULT_INDEX,
+ openByMouse: true,
+ });
+ Assert.ok(!menuitem, "Menuitem should be absent before closing the view");
+ gURLBar.view.resultMenu.hidePopup(true);
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ await doShowLessFrequently({
+ input: "pocket s",
+ expected: {
+ isSuggestionShown: false,
+ },
+ });
+
+ await doShowLessFrequently({
+ input: "pocket su",
+ expected: {
+ isSuggestionShown: true,
+ isMenuItemShown: false,
+ },
+ });
+
+ await QuickSuggestTestUtils.setConfig(QuickSuggestTestUtils.DEFAULT_CONFIG);
+ await SpecialPowers.popPrefEnv();
+ await QuickSuggestTestUtils.forceSync();
+});
+
+async function doShowLessFrequently({ input, expected, keepViewOpen = false }) {
+ 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.telemetryType,
+ "pocket",
+ `Pocket suggestion should be absent (checking index ${i})`
+ );
+ }
+
+ return;
+ }
+
+ const details = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ EXPECTED_RESULT_INDEX
+ );
+ Assert.equal(
+ details.result.payload.telemetryType,
+ "pocket",
+ `Pocket suggestion should be present at expected index after ${input} search`
+ );
+
+ // Click the command.
+ try {
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ "show_less_frequently",
+ {
+ resultIndex: EXPECTED_RESULT_INDEX,
+ }
+ );
+ 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"
+ );
+ }
+
+ if (!keepViewOpen) {
+ await UrlbarTestUtils.promisePopupClose(window);
+ }
+}
+
+// Tests the "Not interested" result menu dismissal command.
+add_task(async function resultMenu_notInterested() {
+ await doDismissTest("not_interested");
+
+ // Re-enable suggestions and wait until PocketSuggestions syncs them from
+ // remote settings again.
+ UrlbarPrefs.set("suggest.pocket", true);
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// Tests the "Not relevant" result menu dismissal command.
+add_task(async function notRelevant() {
+ await doDismissTest("not_relevant");
+});
+
+async function doDismissTest(command) {
+ // Do a search.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "pocket suggestion",
+ });
+
+ // Check the result.
+ let resultCount = 2;
+ Assert.equal(
+ UrlbarTestUtils.getResultCount(window),
+ resultCount,
+ "There should be two results"
+ );
+
+ let { result } = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ EXPECTED_RESULT_INDEX
+ );
+ Assert.equal(
+ result.providerName,
+ UrlbarProviderQuickSuggest.name,
+ "The result should be from the expected provider"
+ );
+ Assert.equal(
+ result.payload.telemetryType,
+ "pocket",
+ "The result should be a Pocket result"
+ );
+
+ // Click the command.
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ ["[data-l10n-id=firefox-suggest-command-dont-show-this]", command],
+ { resultIndex: EXPECTED_RESULT_INDEX, openByMouse: true }
+ );
+
+ // 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"
+ );
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ EXPECTED_RESULT_INDEX
+ );
+ 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,
+ EXPECTED_RESULT_INDEX
+ );
+ 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.telemetryType !== "pocket",
+ "Tip result and suggestion should not be present"
+ );
+ }
+
+ gURLBar.handleRevert();
+
+ // Do the search again.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "pocket suggestion",
+ });
+ 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.telemetryType !== "pocket",
+ "Tip result and suggestion should not be present"
+ );
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await QuickSuggest.blockedSuggestions.clear();
+}
+
+// Tests row labels.
+add_task(async function rowLabel() {
+ const testCases = [
+ // high confidence keyword best match
+ {
+ searchString: "high",
+ expected: "Recommended reads",
+ },
+ // low confidence keyword non-best match
+ {
+ searchString: "pocket suggestion",
+ expected: "Firefox Suggest",
+ },
+ ];
+
+ for (const { searchString, expected } of testCases) {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchString,
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ const row = element.row;
+ Assert.equal(row.getAttribute("label"), expected);
+ }
+});
+
+// Tests visibility of "Show less frequently" menu.
+add_task(async function showLessFrequentlyMenuVisibility() {
+ const testCases = [
+ // high confidence keyword best match
+ {
+ searchString: "high",
+ expected: false,
+ },
+ // low confidence keyword non-best match
+ {
+ searchString: "pocket suggestion",
+ expected: true,
+ },
+ ];
+
+ for (const { searchString, expected } of testCases) {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: searchString,
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const details = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ EXPECTED_RESULT_INDEX
+ );
+ Assert.equal(
+ details.result.payload.telemetryType,
+ "pocket",
+ "Pocket suggestion should be present at expected index"
+ );
+
+ const menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({
+ resultIndex: EXPECTED_RESULT_INDEX,
+ openByMouse: true,
+ command: "show_less_frequently",
+ window,
+ });
+ Assert.equal(!!menuitem, expected);
+
+ gURLBar.view.resultMenu.hidePopup(true);
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js
new file mode 100644
index 0000000000..b7c2bdc25c
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_yelp.js
@@ -0,0 +1,429 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test for Yelp suggestions.
+
+const REMOTE_SETTINGS_RECORDS = [
+ {
+ type: "yelp-suggestions",
+ attachment: {
+ subjects: ["ramen"],
+ preModifiers: ["best"],
+ postModifiers: ["delivery"],
+ locationSigns: [{ keyword: "in", needLocation: true }],
+ yelpModifiers: [],
+ icon: "1234",
+ score: 0.5,
+ },
+ },
+];
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_RECORDS,
+ prefs: [
+ ["quicksuggest.rustEnabled", true],
+ ["suggest.quicksuggest.sponsored", true],
+ ["suggest.yelp", true],
+ ["yelp.featureGate", true],
+ ],
+ });
+});
+
+add_task(async function basic() {
+ for (let topPick of [true, false]) {
+ info("Setting yelpPriority: " + topPick);
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.quicksuggest.yelpPriority", topPick]],
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "RaMeN iN tOkYo",
+ });
+
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const details = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ const { result } = details;
+ Assert.equal(
+ result.providerName,
+ UrlbarProviderQuickSuggest.name,
+ "The result should be from the expected provider"
+ );
+ Assert.equal(result.payload.provider, "Yelp");
+ Assert.equal(
+ result.payload.url,
+ "https://www.yelp.com/search?find_desc=RaMeN&find_loc=tOkYo&utm_medium=partner&utm_source=mozilla"
+ );
+ Assert.equal(result.payload.title, "RaMeN iN tOkYo");
+
+ const { row } = details.element;
+ const bottom = row.querySelector(".urlbarView-row-body-bottom");
+ Assert.ok(bottom, "Bottom text element should exist");
+ Assert.ok(
+ BrowserTestUtils.isVisible(bottom),
+ "Bottom text element should be visible"
+ );
+ Assert.equal(
+ bottom.textContent,
+ "Yelp · Sponsored",
+ "Bottom text is correct"
+ );
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await SpecialPowers.popPrefEnv();
+ }
+});
+
+// Tests the "Show less frequently" result menu command.
+add_task(async function resultMenu_show_less_frequently() {
+ info("Test for no yelpMinKeywordLength and no yelpShowLessFrequentlyCap");
+ await doShowLessFrequently({
+ minKeywordLength: 0,
+ frequentlyCap: 0,
+ testData: [
+ {
+ input: "best ra",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: true,
+ },
+ },
+ {
+ input: "best ra",
+ expected: {
+ hasSuggestion: false,
+ },
+ },
+ {
+ input: "best ram",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: true,
+ },
+ },
+ {
+ input: "best ram",
+ expected: {
+ hasSuggestion: false,
+ },
+ },
+ {
+ input: "best rame",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: true,
+ },
+ },
+ {
+ input: "best rame",
+ expected: {
+ hasSuggestion: false,
+ },
+ },
+ ],
+ });
+
+ info("Test whether yelpShowLessFrequentlyCap can work");
+ await doShowLessFrequently({
+ minKeywordLength: 0,
+ frequentlyCap: 2,
+ testData: [
+ {
+ input: "best ra",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: true,
+ },
+ },
+ {
+ input: "best ram",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: true,
+ },
+ },
+ {
+ input: "best ram",
+ expected: {
+ hasSuggestion: false,
+ },
+ },
+ {
+ input: "best rame",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: false,
+ },
+ },
+ {
+ input: "best ramen",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: false,
+ },
+ },
+ ],
+ });
+
+ info(
+ "Test whether local yelp.minKeywordLength pref can override nimbus variable yelpMinKeywordLength"
+ );
+ await doShowLessFrequently({
+ minKeywordLength: 8,
+ frequentlyCap: 0,
+ testData: [
+ {
+ input: "best ra",
+ expected: {
+ hasSuggestion: false,
+ },
+ },
+ {
+ input: "best ram",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: true,
+ },
+ },
+ {
+ input: "best rame",
+ expected: {
+ hasSuggestion: true,
+ hasShowLessItem: true,
+ },
+ },
+ {
+ input: "best rame",
+ expected: {
+ hasSuggestion: false,
+ },
+ },
+ ],
+ });
+});
+
+async function doShowLessFrequently({
+ minKeywordLength,
+ frequentlyCap,
+ testData,
+}) {
+ UrlbarPrefs.clear("yelp.showLessFrequentlyCount");
+ UrlbarPrefs.clear("yelp.minKeywordLength");
+
+ let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({
+ yelpMinKeywordLength: minKeywordLength,
+ yelpShowLessFrequentlyCap: frequentlyCap,
+ });
+
+ for (let { input, expected } of testData) {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: input,
+ });
+
+ if (expected.hasSuggestion) {
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(
+ window,
+ resultIndex
+ );
+ Assert.equal(details.result.payload.provider, "Yelp");
+
+ if (expected.hasShowLessItem) {
+ // Click the command.
+ let previousShowLessFrequentlyCount = UrlbarPrefs.get(
+ "yelp.showLessFrequentlyCount"
+ );
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ "show_less_frequently",
+ { resultIndex, openByMouse: true }
+ );
+
+ Assert.equal(
+ UrlbarPrefs.get("yelp.showLessFrequentlyCount"),
+ previousShowLessFrequentlyCount + 1
+ );
+ Assert.equal(
+ UrlbarPrefs.get("yelp.minKeywordLength"),
+ input.length + 1
+ );
+ } else {
+ let menuitem = await UrlbarTestUtils.openResultMenuAndGetItem({
+ window,
+ command: "show_less_frequently",
+ resultIndex: 1,
+ openByMouse: true,
+ });
+ Assert.ok(!menuitem);
+ }
+ } else {
+ // Yelp suggestion should not be shown.
+ for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.notEqual(details.result.payload.provider, "Yelp");
+ }
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ }
+
+ await cleanUpNimbus();
+ UrlbarPrefs.clear("yelp.showLessFrequentlyCount");
+ UrlbarPrefs.clear("yelp.minKeywordLength");
+}
+
+// Tests the "Not relevant" result menu dismissal command.
+add_task(async function resultMenu_not_relevant() {
+ await doDismiss({
+ menu: "not_relevant",
+ assert: resuilt => {
+ Assert.ok(
+ QuickSuggest.blockedSuggestions.has(resuilt.payload.url),
+ "The URL should be register as blocked"
+ );
+ },
+ });
+
+ await QuickSuggest.blockedSuggestions.clear();
+});
+
+// Tests the "Not interested" result menu dismissal command.
+add_task(async function resultMenu_not_interested() {
+ await doDismiss({
+ menu: "not_interested",
+ assert: () => {
+ Assert.ok(!UrlbarPrefs.get("suggest.yelp"));
+ },
+ });
+
+ UrlbarPrefs.clear("suggest.yelp");
+});
+
+async function doDismiss({ menu, assert }) {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "ramen",
+ });
+
+ let resultCount = UrlbarTestUtils.getResultCount(window);
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ Assert.equal(details.result.payload.provider, "Yelp");
+ let result = details.result;
+
+ // Click the command.
+ await UrlbarTestUtils.openResultMenuAndClickItem(
+ window,
+ ["[data-l10n-id=firefox-suggest-command-dont-show-this]", menu],
+ {
+ resultIndex,
+ openByMouse: true,
+ }
+ );
+
+ // 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.provider !== "Yelp",
+ "Tip result and Yelp result should not be present"
+ );
+ }
+
+ assert(result);
+
+ await UrlbarTestUtils.promisePopupClose(window);
+
+ // Check that the result should not be shown anymore.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "ramen",
+ });
+
+ for (let i = 0; i < UrlbarTestUtils.getResultCount(window); i++) {
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
+ Assert.ok(
+ details.result.payload.provider !== "Yelp",
+ "Yelp result should not be present"
+ );
+ }
+
+ await UrlbarTestUtils.promisePopupClose(window);
+}
+
+// Tests the row/group label.
+add_task(async function rowLabel() {
+ let tests = [
+ { topPick: true, label: "Local recommendations" },
+ { topPick: false, label: "Firefox Suggest" },
+ ];
+
+ for (let { topPick, label } of tests) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.yelp.priority", topPick]],
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "ramen",
+ });
+ Assert.equal(UrlbarTestUtils.getResultCount(window), 2);
+
+ const { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ const row = element.row;
+ Assert.equal(row.getAttribute("label"), label);
+
+ await UrlbarTestUtils.promisePopupClose(window);
+ await SpecialPowers.popPrefEnv();
+ }
+});
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..001c54458c
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_dynamicWikipedia.js
@@ -0,0 +1,116 @@
+/* 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(),
+ },
+ },
+ },
+ // click
+ click: {
+ 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(),
+ },
+ },
+ },
+ commands: [
+ // dismiss
+ {
+ command: "dismiss",
+ 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
+ {
+ command: "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_gleanEmptyStrings.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js
new file mode 100644
index 0000000000..00cbe6c4e1
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_gleanEmptyStrings.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests that Glean handles empty request IDs properly.
+ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ CONTEXTUAL_SERVICES_PING_TYPES:
+ "resource:///modules/PartnerLinkAttribution.sys.mjs",
+});
+
+const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;
+
+const MERINO_RESULT = {
+ block_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",
+ iab_category: "22 - Shopping",
+ provider: "adm",
+ is_sponsored: true,
+};
+
+const suggestion_type = "sponsored";
+const index = 1;
+const position = index + 1;
+
+// Trying to avoid timeouts in TV mode.
+requestLongerTimeout(3);
+
+add_setup(async function () {
+ await setUpTelemetryTest({
+ merinoSuggestions: [MERINO_RESULT],
+ });
+ MerinoTestUtils.server.response.body.request_id = "";
+});
+
+// sponsored
+add_task(async function sponsored() {
+ let match_type = "firefox-suggest";
+ let source = "merino";
+
+ let improve_suggest_experience_checked = true;
+
+ await doTelemetryTest({
+ index,
+ suggestion: MERINO_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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: MERINO_RESULT.block_id,
+ advertiser: MERINO_RESULT.advertiser,
+ request_id: "",
+ },
+ },
+ },
+ // click
+ click: {
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: true,
+ block_id: MERINO_RESULT.block_id,
+ advertiser: MERINO_RESULT.advertiser,
+ request_id: "",
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
+ payload: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ block_id: MERINO_RESULT.block_id,
+ advertiser: MERINO_RESULT.advertiser,
+ request_id: "",
+ },
+ },
+ ],
+ },
+ commands: [
+ // dismiss
+ {
+ command: "dismiss",
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: MERINO_RESULT.block_id,
+ advertiser: MERINO_RESULT.advertiser,
+ request_id: "",
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ payload: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ block_id: MERINO_RESULT.block_id,
+ advertiser: MERINO_RESULT.advertiser,
+ iab_category: MERINO_RESULT.iab_category,
+ request_id: "",
+ },
+ },
+ ],
+ },
+ // help
+ {
+ command: "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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: MERINO_RESULT.block_id,
+ advertiser: MERINO_RESULT.advertiser,
+ request_id: "",
+ },
+ },
+ ],
+ },
+ ],
+ });
+});
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..8682f1f53a
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js
@@ -0,0 +1,482 @@
+/* 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];
+
+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({
+ remoteSettingsRecords: [
+ {
+ 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([]);
+});
+
+// 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([]);
+ });
+ 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.isHidden(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([]);
+
+ 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([]);
+ });
+});
+
+// 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];
+ let index = 1;
+
+ let pingSubmitted = false;
+ GleanPings.quickSuggest.testBeforeNextSubmit(() => {
+ pingSubmitted = true;
+ Assert.equal(
+ Glean.quickSuggest.pingType.testGetValue(),
+ CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION
+ );
+ Assert.equal(
+ Glean.quickSuggest.improveSuggestExperience.testGetValue(),
+ false
+ );
+ Assert.equal(
+ Glean.quickSuggest.blockId.testGetValue(),
+ firstSuggestion.id
+ );
+ Assert.equal(Glean.quickSuggest.isClicked.testGetValue(), false);
+ Assert.equal(
+ Glean.quickSuggest.matchType.testGetValue(),
+ "firefox-suggest"
+ );
+ Assert.equal(Glean.quickSuggest.position.testGetValue(), index + 1);
+ });
+
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: firstSuggestion.keywords[0],
+ fireInputEvent: true,
+ });
+
+ 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",
+ },
+ },
+ ]);
+ Assert.ok(pingSubmitted, "Glean ping was submitted");
+ });
+});
+
+/**
+ * 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 originalChunkTimeout = UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS;
+ UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = 30000;
+ const cleanup = () => {
+ UrlbarProvidersManager.CHUNK_RESULTS_DELAY_MS = originalChunkTimeout;
+ };
+ registerCleanupFunction(cleanup);
+
+ // 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[0];
+ return state == "engagement";
+ });
+ Assert.equal(engagementCalls.length, 1, "One engagement occurred");
+
+ // Clean up.
+ resolveQuery();
+ UrlbarProvidersManager.unregisterProvider(provider);
+ cleanup();
+ 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..4762095795
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_navigationalSuggestions.js
@@ -0,0 +1,346 @@
+/* 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: [
+ // 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,
+ },
+}) {
+ await PlacesFrecencyRecalculator.recalculateAnyOutdatedFrecencies();
+ MerinoTestUtils.server.response.body.suggestions = suggestion
+ ? [suggestion]
+ : [];
+
+ Services.telemetry.clearEvents();
+
+ 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);
+
+ 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..9a1aa06c02
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_nonsponsored.js
@@ -0,0 +1,236 @@
+/* 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"],
+ advertiser: "Wikipedia",
+ iab_category: "5 - Education",
+ icon: "1234",
+};
+
+const suggestion_type = "nonsponsored";
+const index = 1;
+const position = index + 1;
+
+// Trying to avoid timeouts in TV mode.
+requestLongerTimeout(3);
+
+add_setup(async function () {
+ await setUpTelemetryTest({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: [REMOTE_SETTINGS_RESULT],
+ },
+ ],
+ });
+});
+
+add_tasks_with_rust(async function nonsponsored() {
+ let match_type = "firefox-suggest";
+ let advertiser = REMOTE_SETTINGS_RESULT.advertiser.toLowerCase();
+ let reporting_url = undefined;
+ let source = UrlbarPrefs.get("quicksuggest.rustEnabled")
+ ? "rust"
+ : "remote-settings";
+ let block_id = source == "rust" ? undefined : REMOTE_SETTINGS_RESULT.id;
+
+ // 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: {
+ source,
+ match_type,
+ position,
+ block_id,
+ advertiser,
+ reporting_url,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ },
+ },
+ },
+ // click
+ click: {
+ 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: {
+ source,
+ match_type,
+ position,
+ block_id,
+ advertiser,
+ reporting_url,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: true,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION,
+ payload: {
+ source,
+ match_type,
+ position,
+ block_id,
+ advertiser,
+ reporting_url,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ },
+ },
+ ],
+ },
+ commands: [
+ // dismiss
+ {
+ command: "dismiss",
+ 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: {
+ source,
+ match_type,
+ position,
+ block_id,
+ advertiser,
+ reporting_url,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ },
+ },
+ {
+ type: CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK,
+ payload: {
+ source,
+ match_type,
+ position,
+ block_id,
+ advertiser,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ iab_category: REMOTE_SETTINGS_RESULT.iab_category,
+ },
+ },
+ ],
+ },
+ // help
+ {
+ command: "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: {
+ source,
+ match_type,
+ position,
+ block_id,
+ advertiser,
+ reporting_url,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ },
+ },
+ ],
+ },
+ ],
+ });
+ 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..d40c70107e
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_other.js
@@ -0,0 +1,298 @@
+/* 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({
+ remoteSettingsRecords: [
+ {
+ 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);
+});
+
+// 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..7c477e8af7
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_sponsored.js
@@ -0,0 +1,408 @@
+/* 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",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+};
+
+const suggestion_type = "sponsored";
+const index = 1;
+const position = index + 1;
+
+// Trying to avoid timeouts in TV mode.
+requestLongerTimeout(3);
+
+add_setup(async function () {
+ await setUpTelemetryTest({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: [REMOTE_SETTINGS_RESULT],
+ },
+ ],
+ });
+});
+
+// sponsored
+add_tasks_with_rust(async function sponsored() {
+ let match_type = "firefox-suggest";
+ let source = UrlbarPrefs.get("quicksuggest.rustEnabled")
+ ? "rust"
+ : "remote-settings";
+
+ // 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ },
+ // click
+ click: {
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ commands: [
+ // dismiss
+ {
+ command: "dismiss",
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ iab_category: REMOTE_SETTINGS_RESULT.iab_category,
+ },
+ },
+ ],
+ },
+ // help
+ {
+ command: "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: {
+ source,
+ match_type,
+ position,
+ suggested_index: -1,
+ suggested_index_relative_to_group: true,
+ improve_suggest_experience_checked,
+ is_clicked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ ],
+ });
+ await SpecialPowers.popPrefEnv();
+ }
+});
+
+// higher-placement sponsored, a.k.a sponsored priority, sponsored best match
+add_tasks_with_rust(async function sponsoredBestMatch() {
+ let match_type = "best-match";
+ let source = UrlbarPrefs.get("quicksuggest.rustEnabled")
+ ? "rust"
+ : "remote-settings";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.urlbar.quicksuggest.sponsoredPriority", true]],
+ });
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: 1,
+ suggested_index_relative_to_group: false,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ },
+ // click
+ click: {
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: 1,
+ suggested_index_relative_to_group: false,
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: 1,
+ suggested_index_relative_to_group: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ commands: [
+ // dismiss
+ {
+ command: "dismiss",
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: 1,
+ suggested_index_relative_to_group: false,
+ 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: {
+ source,
+ match_type,
+ position,
+ suggested_index: 1,
+ suggested_index_relative_to_group: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ iab_category: REMOTE_SETTINGS_RESULT.iab_category,
+ },
+ },
+ ],
+ },
+ // help
+ {
+ command: "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: {
+ source,
+ match_type,
+ position,
+ suggested_index: 1,
+ suggested_index_relative_to_group: false,
+ is_clicked: false,
+ improve_suggest_experience_checked: false,
+ block_id: REMOTE_SETTINGS_RESULT.id,
+ advertiser: REMOTE_SETTINGS_RESULT.advertiser,
+ },
+ },
+ ],
+ },
+ ],
+ });
+ 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..e87c64740f
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_weather.js
@@ -0,0 +1,158 @@
+/* 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({
+ remoteSettingsRecords: [
+ {
+ 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;
+
+ // Wait for keywords to be re-synced from remote settings.
+ await QuickSuggestTestUtils.forceSync();
+ }
+ },
+ // 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(),
+ },
+ },
+ },
+ // click
+ click: {
+ 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(),
+ },
+ },
+ },
+ commands: [
+ // not relevant
+ {
+ command: [
+ "[data-l10n-id=firefox-suggest-command-dont-show-this]",
+ "not_relevant",
+ ],
+ scalars: {
+ [WEATHER_SCALARS.IMPRESSION]: position,
+ },
+ event: {
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "engagement",
+ object: "other",
+ extra: {
+ suggestion_type,
+ match_type,
+ position: position.toString(),
+ },
+ },
+ },
+ // help
+ {
+ command: "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..1c3f0e62e7
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/browser_weather.js
@@ -0,0 +1,426 @@
+/* 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({
+ remoteSettingsRecords: [
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ],
+ });
+ await MerinoTestUtils.initWeather();
+});
+
+// Basic checks of the row DOM.
+add_tasks_with_rust(async function dom() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: MerinoTestUtils.WEATHER_KEYWORD,
+ });
+
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ assertIsWeatherResult(details.result, true);
+ let { row } = details.element;
+
+ Assert.ok(
+ BrowserTestUtils.isVisible(
+ row.querySelector(".urlbarView-title-separator")
+ ),
+ "The title separator should be visible"
+ );
+
+ await UrlbarTestUtils.promisePopupClose(window);
+});
+
+// This test ensures the browser navigates to the weather webpage after
+// the weather result is selected.
+add_tasks_with_rust(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_tasks_with_rust(async function showLessFrequentlyCapReached_manySearches() {
+ // Set up a min keyword length and cap.
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ },
+ {
+ type: "configuration",
+ configuration: {
+ show_less_frequently_cap: 1,
+ },
+ },
+ ]);
+
+ // Trigger the suggestion.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "wea",
+ });
+
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ info("Weather suggestion should be present after 'wea' search");
+ assertIsWeatherResult(details.result, true);
+
+ // 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);
+ info(`Weather suggestion should be absent (checking index ${i})`);
+ assertIsWeatherResult(details.result, false);
+ }
+
+ // Do a search using one more character. The suggestion should appear.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "weat",
+ });
+
+ details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ info("Weather suggestion should be present after 'weat' search");
+ assertIsWeatherResult(details.result, true);
+ 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.setRemoteSettingsRecords([
+ {
+ 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_tasks_with_rust(async function showLessFrequentlyCapReached_oneSearch() {
+ // Set up a min keyword length and cap.
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ },
+ {
+ type: "configuration",
+ configuration: {
+ show_less_frequently_cap: 3,
+ },
+ },
+ ]);
+
+ // Trigger the suggestion.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: "wea",
+ });
+
+ let resultIndex = 1;
+ let details = await UrlbarTestUtils.getDetailsOfResultAt(window, resultIndex);
+ info("Weather suggestion should be present after 'wea' search");
+ assertIsWeatherResult(details.result, true);
+
+ 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.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ]);
+ UrlbarPrefs.clear("weather.minKeywordLength");
+});
+
+// Tests the "Not interested" result menu dismissal command.
+add_tasks_with_rust(async function notInterested() {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ value: MerinoTestUtils.WEATHER_KEYWORD,
+ });
+ await doDismissTest("not_interested");
+});
+
+// Tests the "Not relevant" result menu dismissal command.
+add_tasks_with_rust(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);
+ assertIsWeatherResult(details.result, true);
+
+ // 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.notEqual(
+ details.type,
+ UrlbarUtils.RESULT_TYPE.TIP,
+ "Tip result should not be present"
+ );
+ info("Weather result should not be present");
+ assertIsWeatherResult(details.result, false);
+ }
+
+ 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");
+
+ // Wait for keywords to be re-synced from remote settings.
+ await QuickSuggestTestUtils.forceSync();
+}
+
+// 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_tasks_with_rust(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_tasks_with_rust(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);
+ info("Weather suggestion should be present after search");
+ assertIsWeatherResult(details.result, true);
+
+ // 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");
+}
+
+function assertIsWeatherResult(result, isWeatherResult) {
+ let provider = UrlbarPrefs.get("quickSuggestRustEnabled")
+ ? UrlbarProviderQuickSuggest
+ : UrlbarProviderWeather;
+ if (isWeatherResult) {
+ Assert.equal(
+ result.providerName,
+ provider.name,
+ "Result should be from a weather provider"
+ );
+ Assert.equal(
+ UrlbarUtils.searchEngagementTelemetryType(result),
+ "weather",
+ "Result telemetry type should be 'weather'"
+ );
+ } else {
+ Assert.notEqual(
+ result.providerName,
+ provider.name,
+ "Result should not be from a weather provider"
+ );
+ Assert.notEqual(
+ UrlbarUtils.searchEngagementTelemetryType(result),
+ "weather",
+ "Result telemetry type should not be 'weather'"
+ );
+ }
+}
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..7d62a44d45
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/browser/head.js
@@ -0,0 +1,693 @@
+/* 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",
+});
+
+ChromeUtils.defineLazyGetter(this, "QuickSuggestTestUtils", () => {
+ const { QuickSuggestTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/QuickSuggestTestUtils.sys.mjs"
+ );
+ module.init(this);
+ return module;
+});
+
+ChromeUtils.defineLazyGetter(this, "MerinoTestUtils", () => {
+ const { MerinoTestUtils: module } = ChromeUtils.importESModule(
+ "resource://testing-common/MerinoTestUtils.sys.mjs"
+ );
+ module.init(this);
+ return module;
+});
+
+ChromeUtils.defineLazyGetter(this, "PlacesFrecencyRecalculator", () => {
+ return Cc["@mozilla.org/places/frecency-recalculator;1"].getService(
+ Ci.nsIObserver
+ ).wrappedJSObject;
+});
+
+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.remoteSettingsRecords
+ * See `QuickSuggestTestUtils.ensureQuickSuggestInit()`.
+ * @param {Array} options.merinoSuggestions
+ * See `QuickSuggestTestUtils.ensureQuickSuggestInit()`.
+ * @param {Array} options.config
+ * See `QuickSuggestTestUtils.ensureQuickSuggestInit()`.
+ */
+async function setUpTelemetryTest({
+ remoteSettingsRecords,
+ merinoSuggestions = null,
+ config = QuickSuggestTestUtils.DEFAULT_CONFIG,
+}) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // 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();
+
+ // Add a mock engine so we don't hit the network.
+ await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords,
+ 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.click
+ * An object describing the expected click telemetry. It must have the same
+ * properties as `impressionOnly` except `ping` must be `pings` (plural), an
+ * array of expected pings.
+ * @param {Array} options.commands
+ * Each element in this array is an object that describes the expected
+ * telemetry for a result menu command. Each object must have the following
+ * properties:
+ * {string|Array} command
+ * A command name or array; this is passed directly to
+ * `UrlbarTestUtils.openResultMenuAndClickItem()` as the `commandOrArray`
+ * arg, so see its documentation for details.
+ * {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,
+ click,
+ commands,
+ 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,
+ }),
+}) {
+ Services.telemetry.clearScalars();
+ Services.telemetry.clearEvents();
+
+ await doImpressionOnlyTest({
+ index,
+ suggestion,
+ providerName,
+ showSuggestion,
+ expected: impressionOnly,
+ });
+
+ await doClickTest({
+ suggestion,
+ providerName,
+ showSuggestion,
+ index,
+ expected: click,
+ });
+
+ for (let command of commands) {
+ await doCommandTest({
+ suggestion,
+ providerName,
+ showSuggestion,
+ index,
+ commandOrArray: command.command,
+ expected: command,
+ });
+
+ if (teardown) {
+ info("Calling teardown");
+ await teardown();
+ info("Finished teardown");
+ }
+ }
+}
+
+/**
+ * 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.
+ */
+async function doImpressionOnlyTest({
+ index,
+ suggestion,
+ providerName,
+ expected,
+ showSuggestion,
+}) {
+ info("Starting impression-only test");
+
+ Services.telemetry.clearEvents();
+
+ let expectedPings = expected.ping ? [expected.ping] : [];
+ let gleanPingCount = watchGleanPings(expectedPings);
+
+ 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"
+ );
+ return;
+ }
+
+ // 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"
+ );
+ return;
+ }
+
+ // 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]);
+
+ Assert.equal(
+ expectedPings.length,
+ gleanPingCount.value,
+ "Submitted one Glean ping per expected ping"
+ );
+
+ // Clean up.
+ await PlacesUtils.history.clear();
+ await UrlbarTestUtils.formHistory.clear();
+
+ info("Finished impression-only test");
+}
+
+/**
+ * Helper for `doTelemetryTest()` that clicks 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 {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 doClickTest({
+ index,
+ suggestion,
+ providerName,
+ expected,
+ showSuggestion,
+}) {
+ info("Starting click test");
+
+ Services.telemetry.clearEvents();
+
+ let expectedPings = expected.pings ?? [];
+ let gleanPingCount = watchGleanPings(expectedPings);
+
+ info("Showing suggestion");
+ await showSuggestion();
+
+ let row = await validateSuggestionRow(index, suggestion, providerName);
+ if (!row) {
+ Assert.ok(false, "Couldn't get suggestion row, stopping click test");
+ return;
+ }
+
+ // We assume clicking the row will load a page in the current browser.
+ let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ info("Clicking row");
+ EventUtils.synthesizeMouseAtCenter(row, {});
+
+ info("Waiting for load");
+ await loadPromise;
+ await TestUtils.waitForTick();
+
+ info("Checking scalars. Expected: " + JSON.stringify(expected.scalars));
+ QuickSuggestTestUtils.assertScalars(expected.scalars);
+
+ info("Checking events. Expected: " + JSON.stringify([expected.event]));
+ QuickSuggestTestUtils.assertEvents([expected.event]);
+
+ Assert.equal(
+ expectedPings.length,
+ gleanPingCount.value,
+ "Submitted one Glean ping per expected ping"
+ );
+
+ await PlacesUtils.history.clear();
+
+ info("Finished click test");
+}
+
+/**
+ * Helper for `doTelemetryTest()` that clicks a result menu command for a
+ * suggestion 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|Array} options.commandOrArray
+ * A command name or array; this is passed directly to
+ * `UrlbarTestUtils.openResultMenuAndClickItem()` as the `commandOrArray` arg,
+ * so see its documentation for details.
+ * @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 doCommandTest({
+ index,
+ suggestion,
+ providerName,
+ commandOrArray,
+ expected,
+ showSuggestion,
+}) {
+ info("Starting command test: " + JSON.stringify({ commandOrArray }));
+
+ Services.telemetry.clearEvents();
+
+ let expectedPings = expected.pings ?? [];
+ let gleanPingCount = watchGleanPings(expectedPings);
+
+ info("Showing suggestion");
+ await showSuggestion();
+
+ let row = await validateSuggestionRow(index, suggestion, providerName);
+ if (!row) {
+ Assert.ok(false, "Couldn't get suggestion row, stopping click test");
+ return;
+ }
+
+ let command =
+ typeof commandOrArray == "string"
+ ? commandOrArray
+ : commandOrArray[commandOrArray.length - 1];
+
+ let loadPromise;
+ if (command == "help") {
+ // We assume clicking "help" will load a page in a new tab.
+ loadPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ }
+
+ info("Clicking command");
+ await UrlbarTestUtils.openResultMenuAndClickItem(window, commandOrArray, {
+ resultIndex: index,
+ openByMouse: true,
+ });
+
+ if (loadPromise) {
+ info("Waiting for load");
+ await loadPromise;
+ await TestUtils.waitForTick();
+ if (command == "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]);
+
+ Assert.equal(
+ expectedPings.length,
+ gleanPingCount.value,
+ "Submitted one Glean ping per expected ping"
+ );
+
+ if (command == "dismiss") {
+ await QuickSuggest.blockedSuggestions.clear();
+ }
+ await PlacesUtils.history.clear();
+
+ info("Finished command test: " + JSON.stringify({ commandOrArray }));
+}
+
+/**
+ * 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;
+}
+
+function watchGleanPings(pings) {
+ let countObject = { value: 0 };
+
+ let checkPing = (ping, next) => {
+ countObject.value++;
+ _assertGleanPing(ping);
+ if (next) {
+ GleanPings.quickSuggest.testBeforeNextSubmit(next);
+ }
+ };
+
+ // Build the chain of `testBeforeNextSubmit`s backwards.
+ let next = undefined;
+ pings
+ .slice()
+ .reverse()
+ .forEach(ping => {
+ next = checkPing.bind(null, ping, next);
+ });
+ if (next) {
+ GleanPings.quickSuggest.testBeforeNextSubmit(next);
+ }
+
+ return countObject;
+}
+
+function _assertGleanPing(ping) {
+ Assert.equal(Glean.quickSuggest.pingType.testGetValue(), ping.type);
+ const keymap = {
+ // present in all pings
+ source: Glean.quickSuggest.source,
+ match_type: Glean.quickSuggest.matchType,
+ position: Glean.quickSuggest.position,
+ suggested_index: Glean.quickSuggest.suggestedIndex,
+ suggested_index_relative_to_group:
+ Glean.quickSuggest.suggestedIndexRelativeToGroup,
+ improve_suggest_experience_checked:
+ Glean.quickSuggest.improveSuggestExperience,
+ block_id: Glean.quickSuggest.blockId,
+ advertiser: Glean.quickSuggest.advertiser,
+ request_id: Glean.quickSuggest.requestId,
+ context_id: Glean.quickSuggest.contextId,
+ // impression and click pings
+ reporting_url: Glean.quickSuggest.reportingUrl,
+ // impression ping
+ is_clicked: Glean.quickSuggest.isClicked,
+ // block/dismiss ping
+ iab_category: Glean.quickSuggest.iabCategory,
+ };
+ for (let [key, value] of Object.entries(ping.payload)) {
+ Assert.ok(key in keymap, `A Glean metric exists for field ${key}`);
+
+ // Merino results may contain empty strings, but Glean will represent these
+ // as nulls.
+ if (value === "") {
+ value = null;
+ }
+
+ Assert.equal(
+ keymap[key].testGetValue(),
+ value ?? null,
+ `Glean metric field ${key} should be the expected value`
+ );
+ }
+}
+
+/**
+ * Adds two tasks: One with the Rust backend disabled and one with it enabled.
+ * The names of the task functions will be the name of the passed-in task
+ * function appended with "_rustDisabled" and "_rustEnabled" respectively. Call
+ * with the usual `add_task()` arguments.
+ *
+ * @param {...any} args
+ * The usual `add_task()` arguments.
+ */
+function add_tasks_with_rust(...args) {
+ let taskFnIndex = args.findIndex(a => typeof a == "function");
+ let taskFn = args[taskFnIndex];
+
+ for (let rustEnabled of [false, true]) {
+ let newTaskFn = async (...taskFnArgs) => {
+ info("add_tasks_with_rust: Setting rustEnabled: " + rustEnabled);
+ UrlbarPrefs.set("quicksuggest.rustEnabled", rustEnabled);
+ info("add_tasks_with_rust: Done setting rustEnabled: " + rustEnabled);
+
+ // The current backend may now start syncing, so wait for it to finish.
+ info("add_tasks_with_rust: Forcing sync");
+ await QuickSuggestTestUtils.forceSync();
+ info("add_tasks_with_rust: Done forcing sync");
+
+ let rv;
+ try {
+ info(
+ "add_tasks_with_rust: Calling original task function: " + taskFn.name
+ );
+ rv = await taskFn(...taskFnArgs);
+ } finally {
+ info(
+ "add_tasks_with_rust: Done calling original task function: " +
+ taskFn.name
+ );
+ info("add_tasks_with_rust: Clearing rustEnabled");
+ UrlbarPrefs.clear("quicksuggest.rustEnabled");
+ info("add_tasks_with_rust: Done clearing rustEnabled");
+
+ // The current backend may now start syncing, so wait for it to finish.
+ info("add_tasks_with_rust: Forcing sync");
+ await QuickSuggestTestUtils.forceSync();
+ info("add_tasks_with_rust: Done forcing sync");
+ }
+ return rv;
+ };
+
+ Object.defineProperty(newTaskFn, "name", {
+ value: taskFn.name + (rustEnabled ? "_rustEnabled" : "_rustDisabled"),
+ });
+ let addTaskArgs = [...args];
+ addTaskArgs[taskFnIndex] = newTaskFn;
+ add_task(...addTaskArgs);
+ }
+}
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..c468e4526f
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/head.js
@@ -0,0 +1,911 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from ../../unit/head.js */
+/* eslint-disable jsdoc/require-param */
+
+ChromeUtils.defineESModuleGetters(this, {
+ QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ UrlbarProviderAutofill: "resource:///modules/UrlbarProviderAutofill.sys.mjs",
+ UrlbarProviderQuickSuggest:
+ "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
+});
+
+add_setup(async function setUpQuickSuggestXpcshellTest() {
+ // Initializing TelemetryEnvironment in an xpcshell environment requires
+ // jumping through a bunch of hoops. Suggest's use of TelemetryEnvironment is
+ // tested in browser tests, and there's no other necessary reason to wait for
+ // TelemetryEnvironment initialization in xpcshell tests, so just skip it.
+ UrlbarPrefs._testSkipTelemetryEnvironmentInit = true;
+});
+
+/**
+ * Adds two tasks: One with the Rust backend disabled and one with it enabled.
+ * The names of the task functions will be the name of the passed-in task
+ * function appended with "_rustDisabled" and "_rustEnabled". If the passed-in
+ * task doesn't have a name, "anonymousTask" will be used. Call this with the
+ * usual `add_task()` arguments.
+ */
+function add_tasks_with_rust(...args) {
+ let taskFnIndex = args.findIndex(a => typeof a == "function");
+ let taskFn = args[taskFnIndex];
+
+ for (let rustEnabled of [false, true]) {
+ let newTaskFn = async (...taskFnArgs) => {
+ info("add_tasks_with_rust: Setting rustEnabled: " + rustEnabled);
+ UrlbarPrefs.set("quicksuggest.rustEnabled", rustEnabled);
+ info("add_tasks_with_rust: Done setting rustEnabled: " + rustEnabled);
+
+ // The current backend may now start syncing, so wait for it to finish.
+ info("add_tasks_with_rust: Forcing sync");
+ await QuickSuggestTestUtils.forceSync();
+ info("add_tasks_with_rust: Done forcing sync");
+
+ let rv;
+ try {
+ info(
+ "add_tasks_with_rust: Calling original task function: " + taskFn.name
+ );
+ rv = await taskFn(...taskFnArgs);
+ } catch (e) {
+ // Clearly report any unusual errors to make them easier to spot and to
+ // make the flow of the test clearer. The harness throws NS_ERROR_ABORT
+ // when a normal assertion fails, so don't report that.
+ if (e.result != Cr.NS_ERROR_ABORT) {
+ Assert.ok(
+ false,
+ "add_tasks_with_rust: The original task function threw an error: " +
+ e
+ );
+ }
+ throw e;
+ } finally {
+ info(
+ "add_tasks_with_rust: Done calling original task function: " +
+ taskFn.name
+ );
+ info("add_tasks_with_rust: Clearing rustEnabled");
+ UrlbarPrefs.clear("quicksuggest.rustEnabled");
+ info("add_tasks_with_rust: Done clearing rustEnabled");
+
+ // The current backend may now start syncing, so wait for it to finish.
+ info("add_tasks_with_rust: Forcing sync");
+ await QuickSuggestTestUtils.forceSync();
+ info("add_tasks_with_rust: Done forcing sync");
+ }
+ return rv;
+ };
+
+ Object.defineProperty(newTaskFn, "name", {
+ value:
+ (taskFn.name || "anonymousTask") +
+ (rustEnabled ? "_rustEnabled" : "_rustDisabled"),
+ });
+ let addTaskArgs = [...args];
+ addTaskArgs[taskFnIndex] = newTaskFn;
+ add_task(...addTaskArgs);
+ }
+}
+
+/**
+ * Returns an expected Wikipedia (non-sponsored) result that can be passed to
+ * `check_results()` regardless of whether the Rust backend is enabled.
+ *
+ * @returns {object}
+ * An object that can be passed to `check_results()`.
+ */
+function makeWikipediaResult({
+ source,
+ provider,
+ keyword = "wikipedia",
+ title = "Wikipedia Suggestion",
+ url = "http://example.com/wikipedia",
+ originalUrl = "http://example.com/wikipedia",
+ icon = null,
+ iconBlob = new Blob([new Uint8Array([])]),
+ impressionUrl = "http://example.com/wikipedia-impression",
+ clickUrl = "http://example.com/wikipedia-click",
+ blockId = 1,
+ advertiser = "Wikipedia",
+ iabCategory = "5 - Education",
+ suggestedIndex = -1,
+ isSuggestedIndexRelativeToGroup = true,
+}) {
+ let result = {
+ suggestedIndex,
+ isSuggestedIndexRelativeToGroup,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ title,
+ url,
+ originalUrl,
+ displayUrl: url.replace(/^https:\/\//, ""),
+ isSponsored: false,
+ qsSuggestion: keyword,
+ sponsoredAdvertiser: "Wikipedia",
+ sponsoredIabCategory: "5 - Education",
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ telemetryType: "adm_nonsponsored",
+ },
+ };
+
+ if (UrlbarPrefs.get("quickSuggestRustEnabled")) {
+ result.payload.source = source || "rust";
+ result.payload.provider = provider || "Wikipedia";
+ result.payload.iconBlob = iconBlob;
+ } else {
+ result.payload.source = source || "remote-settings";
+ result.payload.provider = provider || "AdmWikipedia";
+ result.payload.icon = icon;
+ result.payload.sponsoredImpressionUrl = impressionUrl;
+ result.payload.sponsoredClickUrl = clickUrl;
+ result.payload.sponsoredBlockId = blockId;
+ result.payload.sponsoredAdvertiser = advertiser;
+ result.payload.sponsoredIabCategory = iabCategory;
+ }
+
+ return result;
+}
+
+/**
+ * Returns an expected AMP (sponsored) result that can be passed to
+ * `check_results()` regardless of whether the Rust backend is enabled.
+ *
+ * @returns {object}
+ * An object that can be passed to `check_results()`.
+ */
+function makeAmpResult({
+ source,
+ provider,
+ keyword = "amp",
+ title = "Amp Suggestion",
+ url = "http://example.com/amp",
+ originalUrl = "http://example.com/amp",
+ icon = null,
+ iconBlob = new Blob([new Uint8Array([])]),
+ impressionUrl = "http://example.com/amp-impression",
+ clickUrl = "http://example.com/amp-click",
+ blockId = 1,
+ advertiser = "Amp",
+ iabCategory = "22 - Shopping",
+ suggestedIndex = -1,
+ isSuggestedIndexRelativeToGroup = true,
+ requestId = undefined,
+} = {}) {
+ let result = {
+ suggestedIndex,
+ isSuggestedIndexRelativeToGroup,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ title,
+ url,
+ originalUrl,
+ requestId,
+ displayUrl: url.replace(/^https:\/\//, ""),
+ isSponsored: true,
+ qsSuggestion: keyword,
+ sponsoredImpressionUrl: impressionUrl,
+ sponsoredClickUrl: clickUrl,
+ sponsoredBlockId: blockId,
+ sponsoredAdvertiser: advertiser,
+ sponsoredIabCategory: iabCategory,
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ telemetryType: "adm_sponsored",
+ descriptionL10n: { id: "urlbar-result-action-sponsored" },
+ },
+ };
+
+ if (UrlbarPrefs.get("quickSuggestRustEnabled")) {
+ result.payload.source = source || "rust";
+ result.payload.provider = provider || "Amp";
+ if (result.payload.source == "rust") {
+ result.payload.iconBlob = iconBlob;
+ } else {
+ result.payload.icon = icon;
+ }
+ } else {
+ result.payload.source = source || "remote-settings";
+ result.payload.provider = provider || "AdmWikipedia";
+ result.payload.icon = icon;
+ }
+
+ return result;
+}
+
+/**
+ * Returns an expected MDN result that can be passed to `check_results()`
+ * regardless of whether the Rust backend is enabled.
+ *
+ * @returns {object}
+ * An object that can be passed to `check_results()`.
+ */
+function makeMdnResult({ url, title, description }) {
+ let finalUrl = new URL(url);
+ finalUrl.searchParams.set("utm_medium", "firefox-desktop");
+ finalUrl.searchParams.set("utm_source", "firefox-suggest");
+ finalUrl.searchParams.set(
+ "utm_campaign",
+ "firefox-mdn-web-docs-suggestion-experiment"
+ );
+ finalUrl.searchParams.set("utm_content", "treatment");
+
+ let result = {
+ isBestMatch: true,
+ suggestedIndex: 1,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK,
+ heuristic: false,
+ payload: {
+ telemetryType: "mdn",
+ title,
+ url: finalUrl.href,
+ originalUrl: url,
+ displayUrl: finalUrl.href.replace(/^https:\/\//, ""),
+ description,
+ icon: "chrome://global/skin/icons/mdn.svg",
+ shouldShowUrl: true,
+ bottomTextL10n: { id: "firefox-suggest-mdn-bottom-text" },
+ },
+ };
+
+ if (UrlbarPrefs.get("quickSuggestRustEnabled")) {
+ result.payload.source = "rust";
+ result.payload.provider = "Mdn";
+ } else {
+ result.payload.source = "remote-settings";
+ result.payload.provider = "MDNSuggestions";
+ }
+
+ return result;
+}
+
+/**
+ * Returns an expected AMO (addons) result that can be passed to
+ * `check_results()` regardless of whether the Rust backend is enabled.
+ *
+ * @returns {object}
+ * An object that can be passed to `check_results()`.
+ */
+function makeAmoResult({
+ source,
+ provider,
+ title = "Amo Suggestion",
+ description = "Amo description",
+ url = "http://example.com/amo",
+ originalUrl = "http://example.com/amo",
+ icon = null,
+ setUtmParams = true,
+}) {
+ if (setUtmParams) {
+ url = new URL(url);
+ url.searchParams.set("utm_medium", "firefox-desktop");
+ url.searchParams.set("utm_source", "firefox-suggest");
+ url = url.href;
+ }
+
+ let result = {
+ isBestMatch: true,
+ suggestedIndex: 1,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ source,
+ provider,
+ title,
+ description,
+ url,
+ originalUrl,
+ icon,
+ displayUrl: url.replace(/^https:\/\//, ""),
+ shouldShowUrl: true,
+ bottomTextL10n: { id: "firefox-suggest-addons-recommended" },
+ helpUrl: QuickSuggest.HELP_URL,
+ telemetryType: "amo",
+ },
+ };
+
+ if (UrlbarPrefs.get("quickSuggestRustEnabled")) {
+ result.payload.source = source || "rust";
+ result.payload.provider = provider || "Amo";
+ } else {
+ result.payload.source = source || "remote-settings";
+ result.payload.provider = provider || "AddonSuggestions";
+ }
+
+ return result;
+}
+
+/**
+ * Returns an expected weather result that can be passed to `check_results()`
+ * regardless of whether the Rust backend is enabled.
+ *
+ * @returns {object}
+ * An object that can be passed to `check_results()`.
+ */
+function makeWeatherResult({
+ source,
+ provider,
+ telemetryType = undefined,
+ temperatureUnit = undefined,
+} = {}) {
+ if (!temperatureUnit) {
+ temperatureUnit =
+ Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
+ }
+
+ let result = {
+ type: UrlbarUtils.RESULT_TYPE.DYNAMIC,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ suggestedIndex: 1,
+ payload: {
+ temperatureUnit,
+ url: MerinoTestUtils.WEATHER_SUGGESTION.url,
+ iconId: "6",
+ helpUrl: QuickSuggest.HELP_URL,
+ requestId: MerinoTestUtils.server.response.body.request_id,
+ source: "merino",
+ provider: "accuweather",
+ dynamicType: "weather",
+ city: MerinoTestUtils.WEATHER_SUGGESTION.city_name,
+ temperature:
+ MerinoTestUtils.WEATHER_SUGGESTION.current_conditions.temperature[
+ temperatureUnit
+ ],
+ currentConditions:
+ MerinoTestUtils.WEATHER_SUGGESTION.current_conditions.summary,
+ forecast: MerinoTestUtils.WEATHER_SUGGESTION.forecast.summary,
+ high: MerinoTestUtils.WEATHER_SUGGESTION.forecast.high[temperatureUnit],
+ low: MerinoTestUtils.WEATHER_SUGGESTION.forecast.low[temperatureUnit],
+ shouldNavigate: true,
+ },
+ };
+
+ if (UrlbarPrefs.get("quickSuggestRustEnabled")) {
+ result.payload.source = source || "rust";
+ result.payload.provider = provider || "Weather";
+ if (telemetryType !== null) {
+ result.payload.telemetryType = telemetryType || "weather";
+ }
+ } else {
+ result.payload.source = source || "merino";
+ result.payload.provider = provider || "accuweather";
+ }
+
+ return result;
+}
+
+/**
+ * Tests quick suggest prefs migrations.
+ *
+ * @param {object} options
+ * The options object.
+ * @param {object} options.testOverrides
+ * An object that modifies how migration is performed. It has the following
+ * properties, and all are optional:
+ *
+ * {number} migrationVersion
+ * Migration will stop at this version, so for example you can test
+ * migration only up to version 1 even when the current actual version is
+ * larger than 1.
+ * {object} defaultPrefs
+ * An object that maps pref names (relative to `browser.urlbar`) to
+ * default-branch values. These should be the default prefs for the given
+ * `migrationVersion` and will be set as defaults before migration occurs.
+ *
+ * @param {string} options.scenario
+ * The scenario to set at the time migration occurs.
+ * @param {object} options.expectedPrefs
+ * The expected prefs after migration: `{ defaultBranch, userBranch }`
+ * Pref names should be relative to `browser.urlbar`.
+ * @param {object} [options.initialUserBranch]
+ * Prefs to set on the user branch before migration ocurs. Use these to
+ * simulate user actions like disabling prefs or opting in or out of the
+ * online modal. Pref names should be relative to `browser.urlbar`.
+ */
+async function doMigrateTest({
+ testOverrides,
+ scenario,
+ expectedPrefs,
+ initialUserBranch = {},
+}) {
+ info(
+ "Testing migration: " +
+ JSON.stringify({
+ testOverrides,
+ initialUserBranch,
+ scenario,
+ expectedPrefs,
+ })
+ );
+
+ function setPref(branch, name, value) {
+ switch (typeof value) {
+ case "boolean":
+ branch.setBoolPref(name, value);
+ break;
+ case "number":
+ branch.setIntPref(name, value);
+ break;
+ case "string":
+ branch.setCharPref(name, value);
+ break;
+ default:
+ Assert.ok(
+ false,
+ `Pref type not handled for setPref: ${name} = ${value}`
+ );
+ break;
+ }
+ }
+
+ function getPref(branch, name) {
+ let type = typeof UrlbarPrefs.get(name);
+ switch (type) {
+ case "boolean":
+ return branch.getBoolPref(name);
+ case "number":
+ return branch.getIntPref(name);
+ case "string":
+ return branch.getCharPref(name);
+ default:
+ Assert.ok(false, `Pref type not handled for getPref: ${name} ${type}`);
+ break;
+ }
+ return null;
+ }
+
+ let defaultBranch = Services.prefs.getDefaultBranch("browser.urlbar.");
+ let userBranch = Services.prefs.getBranch("browser.urlbar.");
+
+ // Set initial prefs. `initialDefaultBranch` are firefox.js values, i.e.,
+ // defaults immediately after startup and before any scenario update and
+ // migration happens.
+ UrlbarPrefs._updatingFirefoxSuggestScenario = true;
+ UrlbarPrefs.clear("quicksuggest.migrationVersion");
+ let initialDefaultBranch = {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": false,
+ };
+ for (let name of Object.keys(initialDefaultBranch)) {
+ userBranch.clearUserPref(name);
+ }
+ for (let [branch, prefs] of [
+ [defaultBranch, initialDefaultBranch],
+ [userBranch, initialUserBranch],
+ ]) {
+ for (let [name, value] of Object.entries(prefs)) {
+ if (value !== undefined) {
+ setPref(branch, name, value);
+ }
+ }
+ }
+ UrlbarPrefs._updatingFirefoxSuggestScenario = false;
+
+ // Update the scenario and check prefs twice. The first time the migration
+ // should happen, and the second time the migration should not happen and
+ // all the prefs should stay the same.
+ for (let i = 0; i < 2; i++) {
+ info(`Calling updateFirefoxSuggestScenario, i=${i}`);
+
+ // Do the scenario update and set `isStartup` to simulate startup.
+ await UrlbarPrefs.updateFirefoxSuggestScenario({
+ ...testOverrides,
+ scenario,
+ isStartup: true,
+ });
+
+ // Check expected pref values. Store expected effective values as we go so
+ // we can check them afterward. For a given pref, the expected effective
+ // value is the user value, or if there's not a user value, the default
+ // value.
+ let expectedEffectivePrefs = {};
+ let {
+ defaultBranch: expectedDefaultBranch,
+ userBranch: expectedUserBranch,
+ } = expectedPrefs;
+ expectedDefaultBranch = expectedDefaultBranch || {};
+ expectedUserBranch = expectedUserBranch || {};
+ for (let [branch, prefs, branchType] of [
+ [defaultBranch, expectedDefaultBranch, "default"],
+ [userBranch, expectedUserBranch, "user"],
+ ]) {
+ let entries = Object.entries(prefs);
+ if (!entries.length) {
+ continue;
+ }
+
+ info(
+ `Checking expected prefs on ${branchType} branch after updating scenario`
+ );
+ for (let [name, value] of entries) {
+ expectedEffectivePrefs[name] = value;
+ if (branch == userBranch) {
+ Assert.ok(
+ userBranch.prefHasUserValue(name),
+ `Pref ${name} is on user branch`
+ );
+ }
+ Assert.equal(
+ getPref(branch, name),
+ value,
+ `Pref ${name} value on ${branchType} branch`
+ );
+ }
+ }
+
+ info(
+ `Making sure prefs on the default branch without expected user-branch values are not on the user branch`
+ );
+ for (let name of Object.keys(initialDefaultBranch)) {
+ if (!expectedUserBranch.hasOwnProperty(name)) {
+ Assert.ok(
+ !userBranch.prefHasUserValue(name),
+ `Pref ${name} is not on user branch`
+ );
+ }
+ }
+
+ info(`Checking expected effective prefs`);
+ for (let [name, value] of Object.entries(expectedEffectivePrefs)) {
+ Assert.equal(
+ UrlbarPrefs.get(name),
+ value,
+ `Pref ${name} effective value`
+ );
+ }
+
+ let currentVersion =
+ testOverrides?.migrationVersion === undefined
+ ? UrlbarPrefs.FIREFOX_SUGGEST_MIGRATION_VERSION
+ : testOverrides.migrationVersion;
+ Assert.equal(
+ UrlbarPrefs.get("quicksuggest.migrationVersion"),
+ currentVersion,
+ "quicksuggest.migrationVersion is correct after migration"
+ );
+ }
+
+ // Clean up.
+ UrlbarPrefs._updatingFirefoxSuggestScenario = true;
+ UrlbarPrefs.clear("quicksuggest.migrationVersion");
+ let userBranchNames = [
+ ...Object.keys(initialUserBranch),
+ ...Object.keys(expectedPrefs.userBranch || {}),
+ ];
+ for (let name of userBranchNames) {
+ userBranch.clearUserPref(name);
+ }
+ UrlbarPrefs._updatingFirefoxSuggestScenario = false;
+}
+
+/**
+ * Does some "show less frequently" tests where the cap is set in remote
+ * settings and Nimbus. See `doOneShowLessFrequentlyTest()`. This function
+ * assumes the matching behavior implemented by the given `BaseFeature` is based
+ * on matching prefixes of the given keyword starting at the first word. It
+ * also assumes the `BaseFeature` provides suggestions in remote settings.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {BaseFeature} options.feature
+ * The feature that provides the suggestion matched by the searches.
+ * @param {*} options.expectedResult
+ * The expected result that should be matched, for searches that are expected
+ * to match a result. Can also be a function; it's passed the current search
+ * string and it should return the expected result.
+ * @param {string} options.showLessFrequentlyCountPref
+ * The name of the pref that stores the "show less frequently" count being
+ * tested.
+ * @param {string} options.nimbusCapVariable
+ * The name of the Nimbus variable that stores the "show less frequently" cap
+ * being tested.
+ * @param {object} options.keyword
+ * The primary keyword to use during the test.
+ * @param {number} options.keywordBaseIndex
+ * The index in `keyword` to base substring checks around. This function will
+ * test substrings starting at the beginning of keyword and ending at the
+ * following indexes: one index before `keywordBaseIndex`,
+ * `keywordBaseIndex`, `keywordBaseIndex` + 1, `keywordBaseIndex` + 2, and
+ * `keywordBaseIndex` + 3.
+ */
+async function doShowLessFrequentlyTests({
+ feature,
+ expectedResult,
+ showLessFrequentlyCountPref,
+ nimbusCapVariable,
+ keyword,
+ keywordBaseIndex = keyword.indexOf(" "),
+}) {
+ // Do some sanity checks on the keyword. Any checks that fail are errors in
+ // the test.
+ if (keywordBaseIndex <= 0) {
+ throw new Error(
+ "keywordBaseIndex must be > 0, but it's " + keywordBaseIndex
+ );
+ }
+ if (keyword.length < keywordBaseIndex + 3) {
+ throw new Error(
+ "keyword must have at least two chars after keywordBaseIndex"
+ );
+ }
+
+ let tests = [
+ {
+ showLessFrequentlyCount: 0,
+ canShowLessFrequently: true,
+ newSearches: {
+ [keyword.substring(0, keywordBaseIndex - 1)]: false,
+ [keyword.substring(0, keywordBaseIndex)]: true,
+ [keyword.substring(0, keywordBaseIndex + 1)]: true,
+ [keyword.substring(0, keywordBaseIndex + 2)]: true,
+ [keyword.substring(0, keywordBaseIndex + 3)]: true,
+ },
+ },
+ {
+ showLessFrequentlyCount: 1,
+ canShowLessFrequently: true,
+ newSearches: {
+ [keyword.substring(0, keywordBaseIndex)]: false,
+ },
+ },
+ {
+ showLessFrequentlyCount: 2,
+ canShowLessFrequently: true,
+ newSearches: {
+ [keyword.substring(0, keywordBaseIndex + 1)]: false,
+ },
+ },
+ {
+ showLessFrequentlyCount: 3,
+ canShowLessFrequently: false,
+ newSearches: {
+ [keyword.substring(0, keywordBaseIndex + 2)]: false,
+ },
+ },
+ {
+ showLessFrequentlyCount: 3,
+ canShowLessFrequently: false,
+ newSearches: {},
+ },
+ ];
+
+ info("Testing 'show less frequently' with cap in remote settings");
+ await doOneShowLessFrequentlyTest({
+ tests,
+ feature,
+ expectedResult,
+ showLessFrequentlyCountPref,
+ rs: {
+ show_less_frequently_cap: 3,
+ },
+ });
+
+ // Nimbus should override remote settings.
+ info("Testing 'show less frequently' with cap in Nimbus and remote settings");
+ await doOneShowLessFrequentlyTest({
+ tests,
+ feature,
+ expectedResult,
+ showLessFrequentlyCountPref,
+ rs: {
+ show_less_frequently_cap: 10,
+ },
+ nimbus: {
+ [nimbusCapVariable]: 3,
+ },
+ });
+}
+
+/**
+ * Does a group of searches, increments the "show less frequently" count, and
+ * repeats until all groups are done. The cap can be set by remote settings
+ * config and/or Nimbus.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {BaseFeature} options.feature
+ * The feature that provides the suggestion matched by the searches.
+ * @param {*} options.expectedResult
+ * The expected result that should be matched, for searches that are expected
+ * to match a result. Can also be a function; it's passed the current search
+ * string and it should return the expected result.
+ * @param {string} options.showLessFrequentlyCountPref
+ * The name of the pref that stores the "show less frequently" count being
+ * tested.
+ * @param {object} options.tests
+ * An array where each item describes a group of new searches to perform and
+ * expected state. Each item should look like this:
+ * `{ showLessFrequentlyCount, canShowLessFrequently, newSearches }`
+ *
+ * {number} showLessFrequentlyCount
+ * The expected value of `showLessFrequentlyCount` before the group of
+ * searches is performed.
+ * {boolean} canShowLessFrequently
+ * The expected value of `canShowLessFrequently` before the group of
+ * searches is performed.
+ * {object} newSearches
+ * An object that maps each search string to a boolean that indicates
+ * whether the first remote settings suggestion should be triggered by the
+ * search string. Searches are cumulative: The intended use is to pass a
+ * large initial group of searches in the first search group, and then each
+ * following `newSearches` is a diff against the previous.
+ * @param {object} options.rs
+ * The remote settings config to set.
+ * @param {object} options.nimbus
+ * The Nimbus variables to set.
+ */
+async function doOneShowLessFrequentlyTest({
+ feature,
+ expectedResult,
+ showLessFrequentlyCountPref,
+ tests,
+ rs = {},
+ nimbus = {},
+}) {
+ // Disable Merino so we trigger only remote settings suggestions. The
+ // `BaseFeature` is expected to add remote settings suggestions using keywords
+ // start starting with the first word in each full keyword, but the mock
+ // Merino server will always return whatever suggestion it's told to return
+ // regardless of the search string. That means Merino will return a suggestion
+ // for a keyword that's smaller than the first full word.
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false);
+
+ // Set Nimbus variables and RS config.
+ let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(nimbus);
+ await QuickSuggestTestUtils.withConfig({
+ config: rs,
+ callback: async () => {
+ let cumulativeSearches = {};
+
+ for (let {
+ showLessFrequentlyCount,
+ canShowLessFrequently,
+ newSearches,
+ } of tests) {
+ info(
+ "Starting subtest: " +
+ JSON.stringify({
+ showLessFrequentlyCount,
+ canShowLessFrequently,
+ newSearches,
+ })
+ );
+
+ Assert.equal(
+ feature.showLessFrequentlyCount,
+ showLessFrequentlyCount,
+ "showLessFrequentlyCount should be correct initially"
+ );
+ Assert.equal(
+ UrlbarPrefs.get(showLessFrequentlyCountPref),
+ showLessFrequentlyCount,
+ "Pref should be correct initially"
+ );
+ Assert.equal(
+ feature.canShowLessFrequently,
+ canShowLessFrequently,
+ "canShowLessFrequently should be correct initially"
+ );
+
+ // Merge the current `newSearches` object into the cumulative object.
+ cumulativeSearches = {
+ ...cumulativeSearches,
+ ...newSearches,
+ };
+
+ for (let [searchString, isExpected] of Object.entries(
+ cumulativeSearches
+ )) {
+ info("Doing search: " + JSON.stringify({ searchString, isExpected }));
+
+ let results = [];
+ if (isExpected) {
+ results.push(
+ typeof expectedResult == "function"
+ ? expectedResult(searchString)
+ : expectedResult
+ );
+ }
+
+ await check_results({
+ context: createContext(searchString, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: results,
+ });
+ }
+
+ feature.incrementShowLessFrequentlyCount();
+ }
+ },
+ });
+
+ await cleanUpNimbus();
+ UrlbarPrefs.clear(showLessFrequentlyCountPref);
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true);
+}
+
+/**
+ * Queries the Rust component directly and checks the returned suggestions. The
+ * point is to make sure the Rust backend passes the correct providers to the
+ * Rust component depending on the types of enabled suggestions. Assuming the
+ * Rust component isn't buggy, it should return suggestions only for the
+ * passed-in providers.
+ *
+ * @param {object} options
+ * Options object
+ * @param {string} options.searchString
+ * The search string.
+ * @param {Array} options.tests
+ * Array of test objects: `{ prefs, expectedUrls }`
+ *
+ * For each object, the given prefs are set, the Rust component is queried
+ * using the given search string, and the URLs of the returned suggestions are
+ * compared to the given expected URLs (order doesn't matter).
+ *
+ * {object} prefs
+ * An object mapping pref names (relative to `browser.urlbar`) to values.
+ * These prefs will be set before querying and should be used to enable or
+ * disable particular types of suggestions.
+ * {Array} expectedUrls
+ * An array of the URLs of the suggestions that are expected to be returned.
+ * The order doesn't matter.
+ */
+async function doRustProvidersTests({ searchString, tests }) {
+ UrlbarPrefs.set("quicksuggest.rustEnabled", true);
+
+ for (let { prefs, expectedUrls } of tests) {
+ info(
+ "Starting Rust providers test: " + JSON.stringify({ prefs, expectedUrls })
+ );
+
+ info("Setting prefs and forcing sync");
+ for (let [name, value] of Object.entries(prefs)) {
+ UrlbarPrefs.set(name, value);
+ }
+ await QuickSuggestTestUtils.forceSync();
+
+ info("Querying with search string: " + JSON.stringify(searchString));
+ let suggestions = await QuickSuggest.rustBackend.query(searchString);
+ info("Got suggestions: " + JSON.stringify(suggestions));
+
+ Assert.deepEqual(
+ suggestions.map(s => s.url).sort(),
+ expectedUrls.sort(),
+ "query() should return the expected suggestions (by URL)"
+ );
+
+ info("Clearing prefs and forcing sync");
+ for (let name of Object.keys(prefs)) {
+ UrlbarPrefs.clear(name);
+ }
+ await QuickSuggestTestUtils.forceSync();
+ }
+
+ info("Clearing rustEnabled pref and forcing sync");
+ UrlbarPrefs.clear("quicksuggest.rustEnabled");
+ await QuickSuggestTestUtils.forceSync();
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js
new file mode 100644
index 0000000000..cd45cb11a7
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient.js
@@ -0,0 +1,647 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test for MerinoClient.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+ MerinoClient: "resource:///modules/MerinoClient.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+// Set the `merino.timeoutMs` pref to a large value so that the client will not
+// inadvertently time out during fetches. This is especially important on CI and
+// when running this test in verify mode. Tasks that specifically test timeouts
+// may need to set a more reasonable value for their duration.
+const TEST_TIMEOUT_MS = 30000;
+
+// The expected suggestion objects returned from `MerinoClient.fetch()`.
+const EXPECTED_MERINO_SUGGESTIONS = [];
+
+const { SEARCH_PARAMS } = MerinoClient;
+
+let gClient;
+
+add_setup(async function init() {
+ UrlbarPrefs.set("merino.timeoutMs", TEST_TIMEOUT_MS);
+ registerCleanupFunction(() => {
+ UrlbarPrefs.clear("merino.timeoutMs");
+ });
+
+ gClient = new MerinoClient();
+ await MerinoTestUtils.server.start();
+
+ for (let suggestion of MerinoTestUtils.server.response.body.suggestions) {
+ EXPECTED_MERINO_SUGGESTIONS.push({
+ ...suggestion,
+ request_id: MerinoTestUtils.server.response.body.request_id,
+ source: "merino",
+ });
+ }
+});
+
+// Checks client names.
+add_task(async function name() {
+ Assert.equal(
+ gClient.name,
+ "anonymous",
+ "gClient name is 'anonymous' since it wasn't given a name"
+ );
+
+ let client = new MerinoClient("New client");
+ Assert.equal(client.name, "New client", "newClient name is correct");
+});
+
+// Does a successful fetch.
+add_task(async function success() {
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ await fetchAndCheckSuggestions({
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "success",
+ "The request successfully finished"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+});
+
+// Does a successful fetch that doesn't return any suggestions.
+add_task(async function noSuggestions() {
+ let { suggestions } = MerinoTestUtils.server.response.body;
+ MerinoTestUtils.server.response.body.suggestions = [];
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ await fetchAndCheckSuggestions({
+ expected: [],
+ });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "no_suggestion",
+ "The request successfully finished without suggestions"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "no_suggestion",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.response.body.suggestions = suggestions;
+});
+
+// Checks a response that's valid but also has some unexpected properties.
+add_task(async function unexpectedResponseProperties() {
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ MerinoTestUtils.server.response.body.unexpectedString = "some value";
+ MerinoTestUtils.server.response.body.unexpectedArray = ["a", "b", "c"];
+ MerinoTestUtils.server.response.body.unexpectedObject = { foo: "bar" };
+
+ await fetchAndCheckSuggestions({
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "success",
+ "The request successfully finished"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+});
+
+// Checks some responses with unexpected response bodies.
+add_task(async function unexpectedResponseBody() {
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ let responses = [
+ { body: {} },
+ { body: { bogus: [] } },
+ { body: { suggestions: {} } },
+ { body: { suggestions: [] } },
+ { body: "" },
+ { body: "bogus", contentType: "text/html" },
+ ];
+
+ for (let r of responses) {
+ info("Testing response: " + JSON.stringify(r));
+
+ MerinoTestUtils.server.response = r;
+ await fetchAndCheckSuggestions({ expected: [] });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "no_suggestion",
+ "The request successfully finished without suggestions"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "no_suggestion",
+ latencyRecorded: true,
+ client: gClient,
+ });
+ }
+
+ MerinoTestUtils.server.reset();
+});
+
+// Tests with a network error.
+add_task(async function networkError() {
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ // This promise will be resolved when the client processes the network error.
+ let responsePromise = gClient.waitForNextResponse();
+
+ await MerinoTestUtils.server.withNetworkError(async () => {
+ await fetchAndCheckSuggestions({ expected: [] });
+ });
+
+ // The client should have nulled out the timeout timer before `fetch()`
+ // returned.
+ Assert.strictEqual(
+ gClient._test_timeoutTimer,
+ null,
+ "timeoutTimer does not exist after fetch finished"
+ );
+
+ // Wait for the client to process the network error.
+ await responsePromise;
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "network_error",
+ "The request failed with a network error"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "network_error",
+ latencyRecorded: false,
+ client: gClient,
+ });
+});
+
+// Tests with an HTTP error.
+add_task(async function httpError() {
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ MerinoTestUtils.server.response = { status: 500 };
+ await fetchAndCheckSuggestions({ expected: [] });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "http_error",
+ "The request failed with an HTTP error"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "http_error",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+});
+
+// Tests a client timeout.
+add_task(async function clientTimeout() {
+ await doClientTimeoutTest({
+ prefTimeoutMs: 200,
+ responseDelayMs: 400,
+ });
+});
+
+// Tests a client timeout followed by an HTTP error. Only the timeout should be
+// recorded.
+add_task(async function clientTimeoutFollowedByHTTPError() {
+ MerinoTestUtils.server.response = { status: 500 };
+ await doClientTimeoutTest({
+ prefTimeoutMs: 200,
+ responseDelayMs: 400,
+ expectedResponseStatus: 500,
+ });
+});
+
+// Tests a client timeout when a timeout value is passed to `fetch()`, which
+// should override the value in the `merino.timeoutMs` pref.
+add_task(async function timeoutPassedToFetch() {
+ // Set up a timeline like this:
+ //
+ // 1ms: The timeout passed to `fetch()` elapses
+ // 400ms: Merino returns a response
+ // 30000ms: The timeout in the pref elapses
+ //
+ // The expected behavior is that the 1ms timeout is hit, the request fails
+ // with a timeout, and Merino later returns a response. If the 1ms timeout is
+ // not hit, then Merino will return a response before the 30000ms timeout
+ // elapses and the request will complete successfully.
+
+ await doClientTimeoutTest({
+ prefTimeoutMs: 30000,
+ responseDelayMs: 400,
+ fetchArgs: { query: "search", timeoutMs: 1 },
+ });
+});
+
+async function doClientTimeoutTest({
+ prefTimeoutMs,
+ responseDelayMs,
+ fetchArgs = { query: "search" },
+ expectedResponseStatus = 200,
+} = {}) {
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ let originalPrefTimeoutMs = UrlbarPrefs.get("merino.timeoutMs");
+ UrlbarPrefs.set("merino.timeoutMs", prefTimeoutMs);
+
+ // Make the server return a delayed response so the client times out waiting
+ // for it.
+ MerinoTestUtils.server.response.delay = responseDelayMs;
+
+ let responsePromise = gClient.waitForNextResponse();
+ await fetchAndCheckSuggestions({ args: fetchArgs, expected: [] });
+
+ Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out");
+
+ // The client should have nulled out the timeout timer.
+ Assert.strictEqual(
+ gClient._test_timeoutTimer,
+ null,
+ "timeoutTimer does not exist after fetch finished"
+ );
+
+ // The fetch controller should still exist because the fetch should remain
+ // ongoing.
+ Assert.ok(
+ gClient._test_fetchController,
+ "fetchController still exists after fetch finished"
+ );
+ Assert.ok(
+ !gClient._test_fetchController.signal.aborted,
+ "fetchController is not aborted"
+ );
+
+ // The latency histogram should not be updated since the response has not been
+ // received.
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "timeout",
+ latencyRecorded: false,
+ latencyStopwatchRunning: true,
+ client: gClient,
+ });
+
+ // Wait for the client to receive the response.
+ let httpResponse = await responsePromise;
+ Assert.ok(httpResponse, "Response was received");
+ Assert.equal(httpResponse.status, expectedResponseStatus, "Response status");
+
+ // The client should have nulled out the fetch controller.
+ Assert.ok(!gClient._test_fetchController, "fetchController no longer exists");
+
+ // The `checkAndClearHistograms()` call above cleared the histograms. After
+ // that, nothing else should have been recorded for the response.
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: null,
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ UrlbarPrefs.set("merino.timeoutMs", originalPrefTimeoutMs);
+}
+
+// By design, when a fetch times out, the client allows it to finish so we can
+// record its latency. But when a second fetch starts before the first finishes,
+// the client should abort the first so that there is at most one fetch at a
+// time.
+add_task(async function newFetchAbortsPrevious() {
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ // Make the server return a very delayed response so that it would time out
+ // and we can start a second fetch that will abort the first fetch.
+ MerinoTestUtils.server.response.delay =
+ 100 * UrlbarPrefs.get("merino.timeoutMs");
+
+ // Do the first fetch.
+ await fetchAndCheckSuggestions({ expected: [] });
+
+ // At this point, the timeout timer has fired, causing our `fetch()` call to
+ // return. However, the client's internal fetch should still be ongoing.
+
+ Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out");
+
+ // The client should have nulled out the timeout timer.
+ Assert.strictEqual(
+ gClient._test_timeoutTimer,
+ null,
+ "timeoutTimer does not exist after first fetch finished"
+ );
+
+ // The fetch controller should still exist because the fetch should remain
+ // ongoing.
+ Assert.ok(
+ gClient._test_fetchController,
+ "fetchController still exists after first fetch finished"
+ );
+ Assert.ok(
+ !gClient._test_fetchController.signal.aborted,
+ "fetchController is not aborted"
+ );
+
+ // The latency histogram should not be updated since the fetch is still
+ // ongoing.
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "timeout",
+ latencyRecorded: false,
+ latencyStopwatchRunning: true,
+ client: gClient,
+ });
+
+ // Do the second fetch. This time don't delay the response.
+ delete MerinoTestUtils.server.response.delay;
+ await fetchAndCheckSuggestions({
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "success",
+ "The request finished successfully"
+ );
+
+ // The fetch was successful, so the client should have nulled out both
+ // properties.
+ Assert.ok(
+ !gClient._test_fetchController,
+ "fetchController does not exist after second fetch finished"
+ );
+ Assert.strictEqual(
+ gClient._test_timeoutTimer,
+ null,
+ "timeoutTimer does not exist after second fetch finished"
+ );
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+});
+
+// The client should not include the `clientVariants` and `providers` search
+// params when they are not set.
+add_task(async function clientVariants_providers_notSet() {
+ UrlbarPrefs.set("merino.clientVariants", "");
+ UrlbarPrefs.set("merino.providers", "");
+
+ await fetchAndCheckSuggestions({
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: "search",
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ },
+ },
+ ]);
+
+ UrlbarPrefs.clear("merino.clientVariants");
+ UrlbarPrefs.clear("merino.providers");
+});
+
+// The client should include the `clientVariants` and `providers` search params
+// when they are set using preferences.
+add_task(async function clientVariants_providers_preferences() {
+ UrlbarPrefs.set("merino.clientVariants", "green");
+ UrlbarPrefs.set("merino.providers", "pink");
+
+ await fetchAndCheckSuggestions({
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: "search",
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ [SEARCH_PARAMS.CLIENT_VARIANTS]: "green",
+ [SEARCH_PARAMS.PROVIDERS]: "pink",
+ },
+ },
+ ]);
+
+ UrlbarPrefs.clear("merino.clientVariants");
+ UrlbarPrefs.clear("merino.providers");
+});
+
+// The client should include the `providers` search param when it's set by
+// passing in the `providers` argument to `fetch()`. The argument should
+// override the pref. This tests a single provider.
+add_task(async function providers_arg_single() {
+ UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed");
+
+ await fetchAndCheckSuggestions({
+ args: { query: "search", providers: ["argShouldBeUsed"] },
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: "search",
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ [SEARCH_PARAMS.PROVIDERS]: "argShouldBeUsed",
+ },
+ },
+ ]);
+
+ UrlbarPrefs.clear("merino.providers");
+});
+
+// The client should include the `providers` search param when it's set by
+// passing in the `providers` argument to `fetch()`. The argument should
+// override the pref. This tests multiple providers.
+add_task(async function providers_arg_many() {
+ UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed");
+
+ await fetchAndCheckSuggestions({
+ args: { query: "search", providers: ["one", "two", "three"] },
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: "search",
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ [SEARCH_PARAMS.PROVIDERS]: "one,two,three",
+ },
+ },
+ ]);
+
+ UrlbarPrefs.clear("merino.providers");
+});
+
+// The client should include the `providers` search param when it's set by
+// passing in the `providers` argument to `fetch()` even when it's an empty
+// array. The argument should override the pref.
+add_task(async function providers_arg_empty() {
+ UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed");
+
+ await fetchAndCheckSuggestions({
+ args: { query: "search", providers: [] },
+ expected: EXPECTED_MERINO_SUGGESTIONS,
+ });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: "search",
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ [SEARCH_PARAMS.PROVIDERS]: "",
+ },
+ },
+ ]);
+
+ UrlbarPrefs.clear("merino.providers");
+});
+
+// Passes invalid `providers` arguments to `fetch()`.
+add_task(async function providers_arg_invalid() {
+ let providersValues = ["", "nonempty", {}];
+
+ for (let providers of providersValues) {
+ info("Calling fetch() with providers: " + JSON.stringify(providers));
+
+ // `Assert.throws()` doesn't seem to work with async functions...
+ let error;
+ try {
+ await gClient.fetch({ providers, query: "search" });
+ } catch (e) {
+ error = e;
+ }
+ Assert.ok(error, "fetch() threw an error");
+ Assert.equal(
+ error.message,
+ "providers must be an array if given",
+ "Expected error was thrown"
+ );
+ }
+});
+
+// Tests setting the endpoint URL and query parameters via Nimbus.
+add_task(async function nimbus() {
+ // Clear the endpoint pref so we know the URL is not being fetched from it.
+ let originalEndpointURL = UrlbarPrefs.get("merino.endpointURL");
+ UrlbarPrefs.set("merino.endpointURL", "");
+
+ await UrlbarTestUtils.initNimbusFeature();
+
+ // First, with the endpoint pref set to an empty string, make sure no Merino
+ // suggestion are returned.
+ await fetchAndCheckSuggestions({ expected: [] });
+
+ // Now install an experiment that sets the endpoint and other Merino-related
+ // variables. Make sure a suggestion is returned and the request includes the
+ // correct query params.
+
+ // `param`: The param name in the request URL
+ // `value`: The value to use for the param
+ // `variable`: The name of the Nimbus variable corresponding to the param
+ let expectedParams = [
+ {
+ param: SEARCH_PARAMS.CLIENT_VARIANTS,
+ value: "test-client-variants",
+ variable: "merinoClientVariants",
+ },
+ {
+ param: SEARCH_PARAMS.PROVIDERS,
+ value: "test-providers",
+ variable: "merinoProviders",
+ },
+ ];
+
+ // Set up the Nimbus variable values to create the experiment with.
+ let experimentValues = {
+ merinoEndpointURL: MerinoTestUtils.server.url.toString(),
+ };
+ for (let { variable, value } of expectedParams) {
+ experimentValues[variable] = value;
+ }
+
+ await withExperiment(experimentValues, async () => {
+ await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS });
+
+ let params = {
+ [SEARCH_PARAMS.QUERY]: "search",
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ };
+ for (let { param, value } of expectedParams) {
+ params[param] = value;
+ }
+ MerinoTestUtils.server.checkAndClearRequests([{ params }]);
+ });
+
+ UrlbarPrefs.set("merino.endpointURL", originalEndpointURL);
+});
+
+async function fetchAndCheckSuggestions({
+ expected,
+ args = {
+ query: "search",
+ },
+}) {
+ let actual = await gClient.fetch(args);
+ Assert.deepEqual(actual, expected, "Expected suggestions");
+ gClient.resetSession();
+}
+
+async function withExperiment(values, callback) {
+ let { enrollmentPromise, doExperimentCleanup } =
+ ExperimentFakes.enrollmentHelper(
+ ExperimentFakes.recipe("mock-experiment", {
+ active: true,
+ branches: [
+ {
+ slug: "treatment",
+ features: [
+ {
+ featureId: NimbusFeatures.urlbar.featureId,
+ value: {
+ enabled: true,
+ ...values,
+ },
+ },
+ ],
+ },
+ ],
+ })
+ );
+ await enrollmentPromise;
+ await callback();
+ await doExperimentCleanup();
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js
new file mode 100644
index 0000000000..b8d62062c0
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_merinoClient_sessions.js
@@ -0,0 +1,402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Test for MerinoClient sessions.
+
+"use strict";
+
+const { MerinoClient } = ChromeUtils.importESModule(
+ "resource:///modules/MerinoClient.sys.mjs"
+);
+
+const { SEARCH_PARAMS } = MerinoClient;
+
+let gClient;
+
+add_setup(async () => {
+ gClient = new MerinoClient();
+ await MerinoTestUtils.server.start();
+});
+
+// In a single session, all requests should use the same session ID and the
+// sequence number should be incremented.
+add_task(async function singleSession() {
+ for (let i = 0; i < 3; i++) {
+ let query = "search" + i;
+ await gClient.fetch({ query });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: i,
+ },
+ },
+ ]);
+ }
+
+ gClient.resetSession();
+});
+
+// Different sessions should use different session IDs and the sequence number
+// should be reset.
+add_task(async function manySessions() {
+ for (let i = 0; i < 3; i++) {
+ let query = "search" + i;
+ await gClient.fetch({ query });
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ },
+ },
+ ]);
+
+ gClient.resetSession();
+ }
+});
+
+// Tests two consecutive fetches:
+//
+// 1. Start a fetch
+// 2. Wait for the mock Merino server to receive the request
+// 3. Start a second fetch before the client receives the response
+//
+// The first fetch will be canceled by the second but the sequence number in the
+// second fetch should still be incremented.
+add_task(async function twoFetches_wait() {
+ for (let i = 0; i < 3; i++) {
+ // Send the first response after a delay to make sure the client will not
+ // receive it before we start the second fetch.
+ MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs");
+
+ // Start the first fetch but don't wait for it to finish.
+ let requestPromise = MerinoTestUtils.server.waitForNextRequest();
+ let query1 = "search" + i;
+ gClient.fetch({ query: query1 });
+
+ // Wait until the first request is received before starting the second
+ // fetch, which will cancel the first. The response doesn't need to be
+ // delayed, so remove it to make the test run faster.
+ await requestPromise;
+ delete MerinoTestUtils.server.response.delay;
+ let query2 = query1 + "again";
+ await gClient.fetch({ query: query2 });
+
+ // The sequence number should have been incremented for each fetch.
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query1,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i,
+ },
+ },
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query2,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1,
+ },
+ },
+ ]);
+ }
+
+ gClient.resetSession();
+});
+
+// Tests two consecutive fetches:
+//
+// 1. Start a fetch
+// 2. Immediately start a second fetch
+//
+// The first fetch will be canceled by the second but the sequence number in the
+// second fetch should still be incremented.
+add_task(async function twoFetches_immediate() {
+ for (let i = 0; i < 3; i++) {
+ // Send the first response after a delay to make sure the client will not
+ // receive it before we start the second fetch.
+ MerinoTestUtils.server.response.delay =
+ 100 * UrlbarPrefs.get("merino.timeoutMs");
+
+ // Start the first fetch but don't wait for it to finish.
+ let query1 = "search" + i;
+ gClient.fetch({ query: query1 });
+
+ // Immediately do a second fetch that cancels the first. The response
+ // doesn't need to be delayed, so remove it to make the test run faster.
+ delete MerinoTestUtils.server.response.delay;
+ let query2 = query1 + "again";
+ await gClient.fetch({ query: query2 });
+
+ // The sequence number should have been incremented for each fetch, but the
+ // first won't have reached the server since it was immediately canceled.
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query2,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1,
+ },
+ },
+ ]);
+ }
+
+ gClient.resetSession();
+});
+
+// When a network error occurs, the sequence number should still be incremented.
+add_task(async function networkError() {
+ for (let i = 0; i < 3; i++) {
+ // Do a fetch that fails with a network error.
+ let query1 = "search" + i;
+ await MerinoTestUtils.server.withNetworkError(async () => {
+ await gClient.fetch({ query: query1 });
+ });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "network_error",
+ "The request failed with a network error"
+ );
+
+ // Do another fetch that successfully finishes.
+ let query2 = query1 + "again";
+ await gClient.fetch({ query: query2 });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "success",
+ "The request completed successfully"
+ );
+
+ // Only the second request should have been received but the sequence number
+ // should have been incremented for each.
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query2,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1,
+ },
+ },
+ ]);
+ }
+
+ gClient.resetSession();
+});
+
+// When the server returns a response with an HTTP error, the sequence number
+// should be incremented.
+add_task(async function httpError() {
+ for (let i = 0; i < 3; i++) {
+ // Do a fetch that fails with an HTTP error.
+ MerinoTestUtils.server.response.status = 500;
+ let query1 = "search" + i;
+ await gClient.fetch({ query: query1 });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "http_error",
+ "The last request failed with a network error"
+ );
+
+ // Do another fetch that successfully finishes.
+ MerinoTestUtils.server.response.status = 200;
+ let query2 = query1 + "again";
+ await gClient.fetch({ query: query2 });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "success",
+ "The last request completed successfully"
+ );
+
+ // Both requests should have been received and the sequence number should
+ // have been incremented for each.
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query1,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i,
+ },
+ },
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query2,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1,
+ },
+ },
+ ]);
+
+ MerinoTestUtils.server.reset();
+ }
+
+ gClient.resetSession();
+});
+
+// When the client times out waiting for a response but later receives it and no
+// other fetch happens in the meantime, the sequence number should be
+// incremented.
+add_task(async function clientTimeout_wait() {
+ for (let i = 0; i < 3; i++) {
+ // Do a fetch that causes the client to time out.
+ MerinoTestUtils.server.response.delay =
+ 2 * UrlbarPrefs.get("merino.timeoutMs");
+ let responsePromise = gClient.waitForNextResponse();
+ let query1 = "search" + i;
+ await gClient.fetch({ query: query1 });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "timeout",
+ "The last request failed with a client timeout"
+ );
+
+ // Wait for the client to receive the response.
+ await responsePromise;
+
+ // Do another fetch that successfully finishes.
+ delete MerinoTestUtils.server.response.delay;
+ let query2 = query1 + "again";
+ await gClient.fetch({ query: query2 });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "success",
+ "The last request completed successfully"
+ );
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query1,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i,
+ },
+ },
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query2,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1,
+ },
+ },
+ ]);
+ }
+
+ gClient.resetSession();
+});
+
+// When the client times out waiting for a response and a second fetch starts
+// before the response is received, the first fetch should be canceled but the
+// sequence number should still be incremented.
+add_task(async function clientTimeout_canceled() {
+ for (let i = 0; i < 3; i++) {
+ // Do a fetch that causes the client to time out.
+ MerinoTestUtils.server.response.delay =
+ 2 * UrlbarPrefs.get("merino.timeoutMs");
+ let query1 = "search" + i;
+ await gClient.fetch({ query: query1 });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "timeout",
+ "The last request failed with a client timeout"
+ );
+
+ // Do another fetch that successfully finishes.
+ delete MerinoTestUtils.server.response.delay;
+ let query2 = query1 + "again";
+ await gClient.fetch({ query: query2 });
+
+ Assert.equal(
+ gClient.lastFetchStatus,
+ "success",
+ "The last request completed successfully"
+ );
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query1,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i,
+ },
+ },
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query2,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1,
+ },
+ },
+ ]);
+ }
+
+ gClient.resetSession();
+});
+
+// When the session times out, the next fetch should use a new session ID and
+// the sequence number should be reset.
+add_task(async function sessionTimeout() {
+ // Set the session timeout to something reasonable to test.
+ let originalTimeoutMs = gClient.sessionTimeoutMs;
+ gClient.sessionTimeoutMs = 500;
+
+ // Do a fetch.
+ let query1 = "search";
+ await gClient.fetch({ query: query1 });
+
+ // Wait for the session to time out.
+ await gClient.waitForNextSessionReset();
+
+ Assert.strictEqual(
+ gClient.sessionID,
+ null,
+ "sessionID is null after session timeout"
+ );
+ Assert.strictEqual(
+ gClient.sequenceNumber,
+ 0,
+ "sequenceNumber is zero after session timeout"
+ );
+ Assert.strictEqual(
+ gClient._test_sessionTimer,
+ null,
+ "sessionTimer is null after session timeout"
+ );
+
+ // Do another fetch.
+ let query2 = query1 + "again";
+ await gClient.fetch({ query: query2 });
+
+ // The second request's sequence number should be zero due to the session
+ // timeout.
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query1,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ },
+ },
+ {
+ params: {
+ [SEARCH_PARAMS.QUERY]: query2,
+ [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ },
+ },
+ ]);
+
+ Assert.ok(
+ gClient.sessionID,
+ "sessionID is non-null after first request in a new session"
+ );
+ Assert.equal(
+ gClient.sequenceNumber,
+ 1,
+ "sequenceNumber is one after first request in a new session"
+ );
+ Assert.ok(
+ gClient._test_sessionTimer,
+ "sessionTimer is non-null after first request in a new session"
+ );
+
+ gClient.sessionTimeoutMs = originalTimeoutMs;
+ gClient.resetSession();
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js
new file mode 100644
index 0000000000..e4c145aabb
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest.js
@@ -0,0 +1,1661 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Basic tests for the quick suggest provider using the remote settings source.
+// See also test_quicksuggest_merino.js.
+
+"use strict";
+
+const TELEMETRY_REMOTE_SETTINGS_LATENCY =
+ "FX_URLBAR_QUICK_SUGGEST_REMOTE_SETTINGS_LATENCY_MS";
+
+const SPONSORED_SEARCH_STRING = "amp";
+const NONSPONSORED_SEARCH_STRING = "wikipedia";
+const SPONSORED_AND_NONSPONSORED_SEARCH_STRING = "sponsored and non-sponsored";
+
+const HTTP_SEARCH_STRING = "http prefix";
+const HTTPS_SEARCH_STRING = "https prefix";
+const PREFIX_SUGGESTIONS_STRIPPED_URL = "example.com/prefix-test";
+
+const { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = QuickSuggest;
+const TIMESTAMP_SEARCH_STRING = "timestamp";
+const TIMESTAMP_SUGGESTION_URL = `http://example.com/timestamp-${TIMESTAMP_TEMPLATE}`;
+const TIMESTAMP_SUGGESTION_CLICK_URL = `http://click.reporting.test.com/timestamp-${TIMESTAMP_TEMPLATE}-foo`;
+
+const REMOTE_SETTINGS_RESULTS = [
+ QuickSuggestTestUtils.ampRemoteSettings({
+ keywords: [
+ SPONSORED_SEARCH_STRING,
+ SPONSORED_AND_NONSPONSORED_SEARCH_STRING,
+ ],
+ }),
+ QuickSuggestTestUtils.wikipediaRemoteSettings({
+ keywords: [
+ NONSPONSORED_SEARCH_STRING,
+ SPONSORED_AND_NONSPONSORED_SEARCH_STRING,
+ ],
+ }),
+ {
+ id: 3,
+ url: "http://" + PREFIX_SUGGESTIONS_STRIPPED_URL,
+ title: "HTTP Suggestion",
+ keywords: [HTTP_SEARCH_STRING],
+ click_url: "http://example.com/http-click",
+ impression_url: "http://example.com/http-impression",
+ advertiser: "HttpAdvertiser",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+ },
+ {
+ id: 4,
+ url: "https://" + PREFIX_SUGGESTIONS_STRIPPED_URL,
+ title: "https suggestion",
+ keywords: [HTTPS_SEARCH_STRING],
+ click_url: "http://click.reporting.test.com/prefix",
+ impression_url: "http://impression.reporting.test.com/prefix",
+ advertiser: "TestAdvertiserPrefix",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+ },
+ {
+ id: 5,
+ url: TIMESTAMP_SUGGESTION_URL,
+ title: "Timestamp suggestion",
+ keywords: [TIMESTAMP_SEARCH_STRING],
+ click_url: TIMESTAMP_SUGGESTION_CLICK_URL,
+ impression_url: "http://impression.reporting.test.com/timestamp",
+ advertiser: "TestAdvertiserTimestamp",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+ },
+];
+
+function expectedNonSponsoredResult() {
+ return makeWikipediaResult({
+ blockId: 2,
+ });
+}
+
+function expectedSponsoredResult() {
+ return makeAmpResult();
+}
+
+function expectedSponsoredPriorityResult() {
+ return {
+ ...expectedSponsoredResult(),
+ isBestMatch: true,
+ suggestedIndex: 1,
+ isSuggestedIndexRelativeToGroup: false,
+ };
+}
+
+function expectedHttpResult() {
+ let suggestion = REMOTE_SETTINGS_RESULTS[2];
+ return makeAmpResult({
+ keyword: HTTP_SEARCH_STRING,
+ title: suggestion.title,
+ url: suggestion.url,
+ originalUrl: suggestion.url,
+ impressionUrl: suggestion.impression_url,
+ clickUrl: suggestion.click_url,
+ blockId: suggestion.id,
+ advertiser: suggestion.advertiser,
+ });
+}
+
+function expectedHttpsResult() {
+ let suggestion = REMOTE_SETTINGS_RESULTS[3];
+ return makeAmpResult({
+ keyword: HTTPS_SEARCH_STRING,
+ title: suggestion.title,
+ url: suggestion.url,
+ originalUrl: suggestion.url,
+ impressionUrl: suggestion.impression_url,
+ clickUrl: suggestion.click_url,
+ blockId: suggestion.id,
+ advertiser: suggestion.advertiser,
+ });
+}
+
+add_setup(async function init() {
+ // Install a default test engine.
+ let engine = await addTestSuggestionsEngine();
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+
+ const testDataTypeResults = [
+ Object.assign({}, REMOTE_SETTINGS_RESULTS[0], { title: "test-data-type" }),
+ ];
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ {
+ type: "test-data-type",
+ attachment: testDataTypeResults,
+ },
+ ],
+ });
+});
+
+add_task(async function telemetryType_sponsored() {
+ Assert.equal(
+ QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({
+ is_sponsored: true,
+ }),
+ "adm_sponsored",
+ "Telemetry type should be 'adm_sponsored'"
+ );
+});
+
+add_task(async function telemetryType_nonsponsored() {
+ Assert.equal(
+ QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({
+ is_sponsored: false,
+ }),
+ "adm_nonsponsored",
+ "Telemetry type should be 'adm_nonsponsored'"
+ );
+ Assert.equal(
+ QuickSuggest.getFeature("AdmWikipedia").getSuggestionTelemetryType({}),
+ "adm_nonsponsored",
+ "Telemetry type should be 'adm_nonsponsored' if `is_sponsored` not defined"
+ );
+});
+
+// Tests with only non-sponsored suggestions enabled with a matching search
+// string.
+add_tasks_with_rust(async function nonsponsoredOnly_match() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(NONSPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedNonSponsoredResult()],
+ });
+
+ // The title should include the full keyword and em dash, and the part of the
+ // title that the search string does not match should be highlighted.
+ let result = context.results[0];
+ Assert.equal(
+ result.title,
+ `${NONSPONSORED_SEARCH_STRING} — Wikipedia Suggestion`,
+ "result.title should be correct"
+ );
+ Assert.deepEqual(
+ result.titleHighlights,
+ [],
+ "result.titleHighlights should be correct"
+ );
+});
+
+// Tests with only non-sponsored suggestions enabled with a non-matching search
+// string.
+add_tasks_with_rust(async function nonsponsoredOnly_noMatch() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({ context, matches: [] });
+});
+
+// Tests with only sponsored suggestions enabled with a matching search string.
+add_tasks_with_rust(async function sponsoredOnly_sponsored() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedSponsoredResult()],
+ });
+
+ // The title should include the full keyword and em dash, and the part of the
+ // title that the search string does not match should be highlighted.
+ let result = context.results[0];
+ Assert.equal(
+ result.title,
+ `${SPONSORED_SEARCH_STRING} — Amp Suggestion`,
+ "result.title should be correct"
+ );
+ Assert.deepEqual(
+ result.titleHighlights,
+ [],
+ "result.titleHighlights should be correct"
+ );
+});
+
+// Tests with only sponsored suggestions enabled with a non-matching search
+// string.
+add_tasks_with_rust(async function sponsoredOnly_nonsponsored() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(NONSPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({ context, matches: [] });
+});
+
+// Tests with both sponsored and non-sponsored suggestions enabled with a
+// search string that matches the sponsored suggestion.
+add_tasks_with_rust(async function both_sponsored() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedSponsoredResult()],
+ });
+});
+
+// Tests with both sponsored and non-sponsored suggestions enabled with a
+// search string that matches the non-sponsored suggestion.
+add_tasks_with_rust(async function both_nonsponsored() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(NONSPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedNonSponsoredResult()],
+ });
+});
+
+// Tests with both sponsored and non-sponsored suggestions enabled with a
+// search string that doesn't match either suggestion.
+add_tasks_with_rust(async function both_noMatch() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext("this doesn't match anything", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({ context, matches: [] });
+});
+
+// Tests with both the main and sponsored prefs disabled with a search string
+// that matches the sponsored suggestion.
+add_tasks_with_rust(async function neither_sponsored() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({ context, matches: [] });
+});
+
+// Tests with both the main and sponsored prefs disabled with a search string
+// that matches the non-sponsored suggestion.
+add_tasks_with_rust(async function neither_nonsponsored() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+
+ let context = createContext(NONSPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({ context, matches: [] });
+});
+
+// Search string matching should be case insensitive and ignore leading spaces.
+add_tasks_with_rust(async function caseInsensitiveAndLeadingSpaces() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(" " + SPONSORED_SEARCH_STRING.toUpperCase(), {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedSponsoredResult()],
+ });
+});
+
+// The provider should not be active for search strings that are empty or
+// contain only spaces.
+add_tasks_with_rust(async function emptySearchStringsAndSpaces() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let searchStrings = ["", " ", " ", " "];
+ for (let str of searchStrings) {
+ let msg = JSON.stringify(str) + ` (length = ${str.length})`;
+ info("Testing search string: " + msg);
+
+ let context = createContext(str, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+ Assert.ok(
+ !UrlbarProviderQuickSuggest.isActive(context),
+ "Provider should not be active for search string: " + msg
+ );
+ }
+});
+
+// Results should be returned even when `browser.search.suggest.enabled` is
+// false.
+add_tasks_with_rust(async function browser_search_suggest_enabled() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("browser.search.suggest.enabled", false);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedSponsoredResult()],
+ });
+
+ UrlbarPrefs.clear("browser.search.suggest.enabled");
+});
+
+// Results should be returned even when `browser.urlbar.suggest.searches` is
+// false.
+add_tasks_with_rust(async function browser_search_suggest_enabled() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("suggest.searches", false);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedSponsoredResult()],
+ });
+
+ UrlbarPrefs.clear("suggest.searches");
+});
+
+// Neither sponsored nor non-sponsored results should appear in private contexts
+// even when suggestions in private windows are enabled.
+add_tasks_with_rust(async function privateContext() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ for (let privateSuggestionsEnabled of [true, false]) {
+ UrlbarPrefs.set(
+ "browser.search.suggest.enabled.private",
+ privateSuggestionsEnabled
+ );
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: true,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+ }
+
+ UrlbarPrefs.clear("browser.search.suggest.enabled.private");
+});
+
+// When search suggestions come before general results and the only general
+// result is a quick suggest result, it should come last.
+add_tasks_with_rust(async function suggestionsBeforeGeneral_only() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("browser.search.suggest.enabled", true);
+ UrlbarPrefs.set("suggest.searches", true);
+ UrlbarPrefs.set("showSearchSuggestionsFirst", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false });
+ await check_results({
+ context,
+ matches: [
+ makeSearchResult(context, {
+ heuristic: true,
+ query: SPONSORED_SEARCH_STRING,
+ engineName: Services.search.defaultEngine.name,
+ }),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " foo",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " bar",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ expectedSponsoredResult(),
+ ],
+ });
+
+ UrlbarPrefs.clear("browser.search.suggest.enabled");
+ UrlbarPrefs.clear("suggest.searches");
+ UrlbarPrefs.clear("showSearchSuggestionsFirst");
+});
+
+// When search suggestions come before general results and there are other
+// general results besides quick suggest, the quick suggest result should come
+// last.
+add_tasks_with_rust(async function suggestionsBeforeGeneral_others() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("browser.search.suggest.enabled", true);
+ UrlbarPrefs.set("suggest.searches", true);
+ UrlbarPrefs.set("showSearchSuggestionsFirst", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false });
+
+ // Add some history that will match our query below.
+ let maxResults = UrlbarPrefs.get("maxRichResults");
+ let historyResults = [];
+ for (let i = 0; i < maxResults; i++) {
+ let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i;
+ historyResults.push(
+ makeVisitResult(context, {
+ uri: url,
+ title: "test visit for " + url,
+ })
+ );
+ await PlacesTestUtils.addVisits(url);
+ }
+ historyResults = historyResults.reverse().slice(0, historyResults.length - 4);
+
+ await check_results({
+ context,
+ matches: [
+ makeSearchResult(context, {
+ heuristic: true,
+ query: SPONSORED_SEARCH_STRING,
+ engineName: Services.search.defaultEngine.name,
+ }),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " foo",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " bar",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ ...historyResults,
+ expectedSponsoredResult(),
+ ],
+ });
+
+ UrlbarPrefs.clear("browser.search.suggest.enabled");
+ UrlbarPrefs.clear("suggest.searches");
+ UrlbarPrefs.clear("showSearchSuggestionsFirst");
+ await PlacesUtils.history.clear();
+});
+
+// When general results come before search suggestions and the only general
+// result is a quick suggest result, it should come before suggestions.
+add_tasks_with_rust(async function generalBeforeSuggestions_only() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("browser.search.suggest.enabled", true);
+ UrlbarPrefs.set("suggest.searches", true);
+ UrlbarPrefs.set("showSearchSuggestionsFirst", false);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false });
+ await check_results({
+ context,
+ matches: [
+ makeSearchResult(context, {
+ heuristic: true,
+ query: SPONSORED_SEARCH_STRING,
+ engineName: Services.search.defaultEngine.name,
+ }),
+ expectedSponsoredResult(),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " foo",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " bar",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ ],
+ });
+
+ UrlbarPrefs.clear("browser.search.suggest.enabled");
+ UrlbarPrefs.clear("suggest.searches");
+ UrlbarPrefs.clear("showSearchSuggestionsFirst");
+});
+
+// When general results come before search suggestions and there are other
+// general results besides quick suggest, the quick suggest result should be the
+// last general result.
+add_tasks_with_rust(async function generalBeforeSuggestions_others() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("browser.search.suggest.enabled", true);
+ UrlbarPrefs.set("suggest.searches", true);
+ UrlbarPrefs.set("showSearchSuggestionsFirst", false);
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, { isPrivate: false });
+
+ // Add some history that will match our query below.
+ let maxResults = UrlbarPrefs.get("maxRichResults");
+ let historyResults = [];
+ for (let i = 0; i < maxResults; i++) {
+ let url = "http://example.com/" + SPONSORED_SEARCH_STRING + i;
+ historyResults.push(
+ makeVisitResult(context, {
+ uri: url,
+ title: "test visit for " + url,
+ })
+ );
+ await PlacesTestUtils.addVisits(url);
+ }
+ historyResults = historyResults.reverse().slice(0, historyResults.length - 4);
+
+ await check_results({
+ context,
+ matches: [
+ makeSearchResult(context, {
+ heuristic: true,
+ query: SPONSORED_SEARCH_STRING,
+ engineName: Services.search.defaultEngine.name,
+ }),
+ ...historyResults,
+ expectedSponsoredResult(),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " foo",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ makeSearchResult(context, {
+ query: SPONSORED_SEARCH_STRING,
+ suggestion: SPONSORED_SEARCH_STRING + " bar",
+ engineName: Services.search.defaultEngine.name,
+ }),
+ ],
+ });
+
+ UrlbarPrefs.clear("browser.search.suggest.enabled");
+ UrlbarPrefs.clear("suggest.searches");
+ UrlbarPrefs.clear("showSearchSuggestionsFirst");
+ await PlacesUtils.history.clear();
+});
+
+add_tasks_with_rust(async function dedupeAgainstURL_samePrefix() {
+ await doDedupeAgainstURLTest({
+ searchString: HTTP_SEARCH_STRING,
+ expectedQuickSuggestResult: expectedHttpResult(),
+ otherPrefix: "http://",
+ expectOther: false,
+ });
+});
+
+add_tasks_with_rust(async function dedupeAgainstURL_higherPrefix() {
+ await doDedupeAgainstURLTest({
+ searchString: HTTPS_SEARCH_STRING,
+ expectedQuickSuggestResult: expectedHttpsResult(),
+ otherPrefix: "http://",
+ expectOther: false,
+ });
+});
+
+add_tasks_with_rust(async function dedupeAgainstURL_lowerPrefix() {
+ await doDedupeAgainstURLTest({
+ searchString: HTTP_SEARCH_STRING,
+ expectedQuickSuggestResult: expectedHttpResult(),
+ otherPrefix: "https://",
+ expectOther: true,
+ });
+});
+
+/**
+ * Tests how the muxer dedupes URL results against quick suggest results.
+ * Depending on prefix rank, quick suggest results should be preferred over
+ * other URL results with the same stripped URL: Other results should be
+ * discarded when their prefix rank is lower than the prefix rank of the quick
+ * suggest. They should not be discarded when their prefix rank is higher, and
+ * in that case both results should be included.
+ *
+ * This function adds a visit to the URL formed by the given `otherPrefix` and
+ * `PREFIX_SUGGESTIONS_STRIPPED_URL`. The visit's title will be set to the given
+ * `searchString` so that both the visit and the quick suggest will match it.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {string} options.searchString
+ * The search string that should trigger one of the mock prefix-test quick
+ * suggest results.
+ * @param {object} options.expectedQuickSuggestResult
+ * The expected quick suggest result.
+ * @param {string} options.otherPrefix
+ * The visit will be created with a URL with this prefix, e.g., "http://".
+ * @param {boolean} options.expectOther
+ * Whether the visit result should appear in the final results.
+ */
+async function doDedupeAgainstURLTest({
+ searchString,
+ expectedQuickSuggestResult,
+ otherPrefix,
+ expectOther,
+}) {
+ // Disable search suggestions.
+ UrlbarPrefs.set("suggest.searches", false);
+
+ // Add a visit that will match our query below.
+ let otherURL = otherPrefix + PREFIX_SUGGESTIONS_STRIPPED_URL;
+ await PlacesTestUtils.addVisits({ uri: otherURL, title: searchString });
+
+ // First, do a search with quick suggest disabled to make sure the search
+ // string matches the visit.
+ info("Doing first query");
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+ let context = createContext(searchString, { isPrivate: false });
+ await check_results({
+ context,
+ matches: [
+ makeSearchResult(context, {
+ heuristic: true,
+ query: searchString,
+ engineName: Services.search.defaultEngine.name,
+ }),
+ makeVisitResult(context, {
+ uri: otherURL,
+ title: searchString,
+ }),
+ ],
+ });
+
+ // Now do another search with quick suggest enabled.
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ context = createContext(searchString, { isPrivate: false });
+
+ let expectedResults = [
+ makeSearchResult(context, {
+ heuristic: true,
+ query: searchString,
+ engineName: Services.search.defaultEngine.name,
+ }),
+ ];
+ if (expectOther) {
+ expectedResults.push(
+ makeVisitResult(context, {
+ uri: otherURL,
+ title: searchString,
+ })
+ );
+ }
+ expectedResults.push(expectedQuickSuggestResult);
+
+ info("Doing second query");
+ await check_results({ context, matches: expectedResults });
+
+ UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored");
+ UrlbarPrefs.clear("suggest.quicksuggest.sponsored");
+ await QuickSuggestTestUtils.forceSync();
+
+ UrlbarPrefs.clear("suggest.searches");
+ await PlacesUtils.history.clear();
+}
+
+// Tests the remote settings latency histogram.
+add_task(
+ {
+ // Not supported by the Rust backend.
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function latencyTelemetry() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let histogram = Services.telemetry.getHistogramById(
+ TELEMETRY_REMOTE_SETTINGS_LATENCY
+ );
+ histogram.clear();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedSponsoredResult()],
+ });
+
+ // In the latency histogram, there should be a single value across all
+ // buckets.
+ Assert.deepEqual(
+ Object.values(histogram.snapshot().values).filter(v => v > 0),
+ [1],
+ "Latency histogram updated after search"
+ );
+ Assert.ok(
+ !TelemetryStopwatch.running(TELEMETRY_REMOTE_SETTINGS_LATENCY, context),
+ "Stopwatch not running after search"
+ );
+ }
+);
+
+// Tests setup and teardown of the remote settings client depending on whether
+// quick suggest is enabled.
+add_task(
+ {
+ // Not supported by the Rust backend.
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function setupAndTeardown() {
+ Assert.ok(
+ QuickSuggest.jsBackend.isEnabled,
+ "Remote settings backend is enabled initially"
+ );
+
+ // Disable the suggest prefs so the settings client starts out torn down.
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+ Assert.ok(
+ !QuickSuggest.jsBackend.rs,
+ "Settings client is null after disabling suggest prefs"
+ );
+ Assert.ok(
+ QuickSuggest.jsBackend.isEnabled,
+ "Remote settings backend remains enabled"
+ );
+
+ // Setting one of the suggest prefs should cause the client to be set up. We
+ // assume all previous tasks left `quicksuggest.enabled` true (from the init
+ // task).
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ Assert.ok(
+ QuickSuggest.jsBackend.rs,
+ "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored"
+ );
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ Assert.ok(
+ !QuickSuggest.jsBackend.rs,
+ "Settings client is null after disabling suggest.quicksuggest.nonsponsored"
+ );
+
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ Assert.ok(
+ QuickSuggest.jsBackend.rs,
+ "Settings client is non-null after enabling suggest.quicksuggest.sponsored"
+ );
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ Assert.ok(
+ QuickSuggest.jsBackend.rs,
+ "Settings client remains non-null after enabling suggest.quicksuggest.nonsponsored"
+ );
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ Assert.ok(
+ QuickSuggest.jsBackend.rs,
+ "Settings client remains non-null after disabling suggest.quicksuggest.nonsponsored"
+ );
+
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+ Assert.ok(
+ !QuickSuggest.jsBackend.rs,
+ "Settings client is null after disabling suggest.quicksuggest.sponsored"
+ );
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ Assert.ok(
+ QuickSuggest.jsBackend.rs,
+ "Settings client is non-null after enabling suggest.quicksuggest.nonsponsored"
+ );
+
+ UrlbarPrefs.set("quicksuggest.enabled", false);
+ Assert.ok(
+ !QuickSuggest.jsBackend.rs,
+ "Settings client is null after disabling quicksuggest.enabled"
+ );
+
+ UrlbarPrefs.set("quicksuggest.enabled", true);
+ Assert.ok(
+ QuickSuggest.jsBackend.rs,
+ "Settings client is non-null after re-enabling quicksuggest.enabled"
+ );
+ Assert.ok(
+ QuickSuggest.jsBackend.isEnabled,
+ "Remote settings backend is enabled after re-enabling quicksuggest.enabled"
+ );
+
+ UrlbarPrefs.set("quicksuggest.rustEnabled", true);
+ Assert.ok(
+ !QuickSuggest.jsBackend.rs,
+ "Settings client is null after enabling the Rust backend"
+ );
+ Assert.ok(
+ !QuickSuggest.jsBackend.isEnabled,
+ "Remote settings backend is disabled after enabling the Rust backend"
+ );
+
+ UrlbarPrefs.clear("quicksuggest.rustEnabled");
+ Assert.ok(
+ QuickSuggest.jsBackend.rs,
+ "Settings client is non-null after disabling the Rust backend"
+ );
+ Assert.ok(
+ QuickSuggest.jsBackend.isEnabled,
+ "Remote settings backend is enabled after disabling the Rust backend"
+ );
+
+ // Leave the prefs in the same state as when the task started.
+ UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored");
+ UrlbarPrefs.clear("suggest.quicksuggest.sponsored");
+ UrlbarPrefs.set("quicksuggest.enabled", true);
+ Assert.ok(
+ !QuickSuggest.jsBackend.rs,
+ "Settings client remains null at end of task"
+ );
+ }
+);
+
+// Timestamp templates in URLs should be replaced with real timestamps.
+add_tasks_with_rust(async function timestamps() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Do a search.
+ let context = createContext(TIMESTAMP_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ let controller = UrlbarTestUtils.newMockController();
+ await controller.startQuery(context);
+
+ // Should be one quick suggest result.
+ Assert.equal(context.results.length, 1, "One result returned");
+ let result = context.results[0];
+
+ QuickSuggestTestUtils.assertTimestampsReplaced(result, {
+ url: TIMESTAMP_SUGGESTION_URL,
+ sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL,
+ });
+});
+
+// Real quick suggest URLs include a timestamp template that
+// UrlbarProviderQuickSuggest fills in when it fetches suggestions. When the
+// user picks a quick suggest, its URL with its particular timestamp is added to
+// history. If the user triggers the quick suggest again later, its new
+// timestamp may be different from the one in the user's history. In that case,
+// the two URLs should be treated as dupes and only the quick suggest should be
+// shown, not the URL from history.
+add_tasks_with_rust(async function dedupeAgainstURL_timestamps() {
+ // Disable search suggestions.
+ UrlbarPrefs.set("suggest.searches", false);
+
+ // Add a visit that will match the query below and dupe the quick suggest.
+ let dupeURL = TIMESTAMP_SUGGESTION_URL.replace(
+ TIMESTAMP_TEMPLATE,
+ "2013051113"
+ );
+
+ // Add other visits that will match the query and almost dupe the quick
+ // suggest but not quite because they have invalid timestamps.
+ let badTimestamps = [
+ // not numeric digits
+ "x".repeat(TIMESTAMP_LENGTH),
+ // too few digits
+ "5".repeat(TIMESTAMP_LENGTH - 1),
+ // empty string, too few digits
+ "",
+ ];
+ let badTimestampURLs = badTimestamps.map(str =>
+ TIMESTAMP_SUGGESTION_URL.replace(TIMESTAMP_TEMPLATE, str)
+ );
+
+ await PlacesTestUtils.addVisits(
+ [dupeURL, ...badTimestampURLs].map(uri => ({
+ uri,
+ title: TIMESTAMP_SEARCH_STRING,
+ }))
+ );
+
+ // First, do a search with quick suggest disabled to make sure the search
+ // string matches all the other URLs.
+ info("Doing first query");
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+ let context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false });
+
+ let expectedHeuristic = makeSearchResult(context, {
+ heuristic: true,
+ query: TIMESTAMP_SEARCH_STRING,
+ engineName: Services.search.defaultEngine.name,
+ });
+ let expectedDupeResult = makeVisitResult(context, {
+ uri: dupeURL,
+ title: TIMESTAMP_SEARCH_STRING,
+ });
+ let expectedBadTimestampResults = [...badTimestampURLs].reverse().map(uri =>
+ makeVisitResult(context, {
+ uri,
+ title: TIMESTAMP_SEARCH_STRING,
+ })
+ );
+
+ await check_results({
+ context,
+ matches: [
+ expectedHeuristic,
+ ...expectedBadTimestampResults,
+ expectedDupeResult,
+ ],
+ });
+
+ // Now do another search with quick suggest enabled.
+ info("Doing second query");
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+ context = createContext(TIMESTAMP_SEARCH_STRING, { isPrivate: false });
+
+ let expectedQuickSuggest = makeAmpResult({
+ originalUrl: TIMESTAMP_SUGGESTION_URL,
+ keyword: TIMESTAMP_SEARCH_STRING,
+ title: "Timestamp suggestion",
+ impressionUrl: "http://impression.reporting.test.com/timestamp",
+ blockId: 5,
+ advertiser: "TestAdvertiserTimestamp",
+ iabCategory: "22 - Shopping",
+ });
+
+ let expectedResults = [
+ expectedHeuristic,
+ ...expectedBadTimestampResults,
+ expectedQuickSuggest,
+ ];
+
+ let controller = UrlbarTestUtils.newMockController();
+ await controller.startQuery(context);
+ info("Actual results: " + JSON.stringify(context.results));
+
+ Assert.equal(
+ context.results.length,
+ expectedResults.length,
+ "Found the expected number of results"
+ );
+
+ function getPayload(result, keysToIgnore = []) {
+ let payload = {};
+ for (let [key, value] of Object.entries(result.payload)) {
+ if (value !== undefined && !keysToIgnore.includes(key)) {
+ payload[key] = value;
+ }
+ }
+ return payload;
+ }
+
+ // Check actual vs. expected result properties.
+ for (let i = 0; i < expectedResults.length; i++) {
+ let actual = context.results[i];
+ let expected = expectedResults[i];
+ info(
+ `Comparing results at index ${i}:` +
+ " actual=" +
+ JSON.stringify(actual) +
+ " expected=" +
+ JSON.stringify(expected)
+ );
+ Assert.equal(
+ actual.type,
+ expected.type,
+ `result.type at result index ${i}`
+ );
+ Assert.equal(
+ actual.source,
+ expected.source,
+ `result.source at result index ${i}`
+ );
+ Assert.equal(
+ actual.heuristic,
+ expected.heuristic,
+ `result.heuristic at result index ${i}`
+ );
+
+ // Check payloads except for the last result, which should be the quick
+ // suggest.
+ if (i != expectedResults.length - 1) {
+ Assert.deepEqual(
+ getPayload(context.results[i]),
+ getPayload(expectedResults[i]),
+ "Payload at index " + i
+ );
+ }
+ }
+
+ // Check the quick suggest's payload excluding the timestamp-related
+ // properties.
+ let actualQuickSuggest = context.results[context.results.length - 1];
+ let timestampKeys = [
+ "displayUrl",
+ "sponsoredClickUrl",
+ "url",
+ "urlTimestampIndex",
+ ];
+ Assert.deepEqual(
+ getPayload(actualQuickSuggest, timestampKeys),
+ getPayload(expectedQuickSuggest, timestampKeys),
+ "Quick suggest payload excluding timestamp-related keys"
+ );
+
+ // Now check the timestamps in the payload.
+ QuickSuggestTestUtils.assertTimestampsReplaced(actualQuickSuggest, {
+ url: TIMESTAMP_SUGGESTION_URL,
+ sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL,
+ });
+
+ // Clean up.
+ UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored");
+ UrlbarPrefs.clear("suggest.quicksuggest.sponsored");
+ await QuickSuggestTestUtils.forceSync();
+
+ UrlbarPrefs.clear("suggest.searches");
+ await PlacesUtils.history.clear();
+});
+
+// Tests the API for blocking suggestions and the backing pref.
+add_task(async function blockedSuggestionsAPI() {
+ // Start with no blocked suggestions.
+ await QuickSuggest.blockedSuggestions.clear();
+ Assert.equal(
+ QuickSuggest.blockedSuggestions._test_digests.size,
+ 0,
+ "blockedSuggestions._test_digests is empty"
+ );
+ Assert.equal(
+ UrlbarPrefs.get("quicksuggest.blockedDigests"),
+ "",
+ "quicksuggest.blockedDigests is an empty string"
+ );
+
+ // Make some URLs.
+ let urls = [];
+ for (let i = 0; i < 3; i++) {
+ urls.push("http://example.com/" + i);
+ }
+
+ // Block each URL in turn and make sure previously blocked URLs are still
+ // blocked and the remaining URLs are not blocked.
+ for (let i = 0; i < urls.length; i++) {
+ await QuickSuggest.blockedSuggestions.add(urls[i]);
+ for (let j = 0; j < urls.length; j++) {
+ Assert.equal(
+ await QuickSuggest.blockedSuggestions.has(urls[j]),
+ j <= i,
+ `Suggestion at index ${j} is blocked or not as expected`
+ );
+ }
+ }
+
+ // Make sure all URLs are blocked for good measure.
+ for (let url of urls) {
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(url),
+ `Suggestion is blocked: ${url}`
+ );
+ }
+
+ // Check `blockedSuggestions._test_digests` and `quicksuggest.blockedDigests`.
+ Assert.equal(
+ QuickSuggest.blockedSuggestions._test_digests.size,
+ urls.length,
+ "blockedSuggestions._test_digests has correct size"
+ );
+ let array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests"));
+ Assert.ok(Array.isArray(array), "Parsed value of pref is an array");
+ Assert.equal(array.length, urls.length, "Array has correct length");
+
+ // Write some junk to `quicksuggest.blockedDigests`.
+ // `blockedSuggestions._test_digests` should not be changed and all previously
+ // blocked URLs should remain blocked.
+ UrlbarPrefs.set("quicksuggest.blockedDigests", "not a json array");
+ await QuickSuggest.blockedSuggestions._test_readyPromise;
+ for (let url of urls) {
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(url),
+ `Suggestion remains blocked: ${url}`
+ );
+ }
+ Assert.equal(
+ QuickSuggest.blockedSuggestions._test_digests.size,
+ urls.length,
+ "blockedSuggestions._test_digests still has correct size"
+ );
+
+ // Block a new URL. All URLs should remain blocked and the pref should be
+ // updated.
+ let newURL = "http://example.com/new-block";
+ await QuickSuggest.blockedSuggestions.add(newURL);
+ urls.push(newURL);
+ for (let url of urls) {
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(url),
+ `Suggestion is blocked: ${url}`
+ );
+ }
+ Assert.equal(
+ QuickSuggest.blockedSuggestions._test_digests.size,
+ urls.length,
+ "blockedSuggestions._test_digests has correct size"
+ );
+ array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests"));
+ Assert.ok(Array.isArray(array), "Parsed value of pref is an array");
+ Assert.equal(array.length, urls.length, "Array has correct length");
+
+ // Add a new URL digest directly to the JSON'ed array in the pref.
+ newURL = "http://example.com/direct-to-pref";
+ urls.push(newURL);
+ array = JSON.parse(UrlbarPrefs.get("quicksuggest.blockedDigests"));
+ array.push(await QuickSuggest.blockedSuggestions._test_getDigest(newURL));
+ UrlbarPrefs.set("quicksuggest.blockedDigests", JSON.stringify(array));
+ await QuickSuggest.blockedSuggestions._test_readyPromise;
+
+ // All URLs should remain blocked and the new URL should be blocked.
+ for (let url of urls) {
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(url),
+ `Suggestion is blocked: ${url}`
+ );
+ }
+ Assert.equal(
+ QuickSuggest.blockedSuggestions._test_digests.size,
+ urls.length,
+ "blockedSuggestions._test_digests has correct size"
+ );
+
+ // Clear the pref. All URLs should be unblocked.
+ UrlbarPrefs.clear("quicksuggest.blockedDigests");
+ await QuickSuggest.blockedSuggestions._test_readyPromise;
+ for (let url of urls) {
+ Assert.ok(
+ !(await QuickSuggest.blockedSuggestions.has(url)),
+ `Suggestion is no longer blocked: ${url}`
+ );
+ }
+ Assert.equal(
+ QuickSuggest.blockedSuggestions._test_digests.size,
+ 0,
+ "blockedSuggestions._test_digests is now empty"
+ );
+
+ // Block all the URLs again and test `blockedSuggestions.clear()`.
+ for (let url of urls) {
+ await QuickSuggest.blockedSuggestions.add(url);
+ }
+ for (let url of urls) {
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(url),
+ `Suggestion is blocked: ${url}`
+ );
+ }
+ await QuickSuggest.blockedSuggestions.clear();
+ for (let url of urls) {
+ Assert.ok(
+ !(await QuickSuggest.blockedSuggestions.has(url)),
+ `Suggestion is no longer blocked: ${url}`
+ );
+ }
+ Assert.equal(
+ QuickSuggest.blockedSuggestions._test_digests.size,
+ 0,
+ "blockedSuggestions._test_digests is now empty"
+ );
+});
+
+// Tests blocking real `UrlbarResult`s.
+add_tasks_with_rust(async function block() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ let tests = [
+ // [suggestion, expected result]
+ [REMOTE_SETTINGS_RESULTS[0], expectedSponsoredResult()],
+ [REMOTE_SETTINGS_RESULTS[1], expectedNonSponsoredResult()],
+ [REMOTE_SETTINGS_RESULTS[2], expectedHttpResult()],
+ [REMOTE_SETTINGS_RESULTS[3], expectedHttpsResult()],
+ ];
+
+ for (let [suggestion, expectedResult] of tests) {
+ info("Testing suggestion: " + JSON.stringify(suggestion));
+
+ // Do a search to get a real `UrlbarResult` created for the suggestion.
+ let context = createContext(suggestion.keywords[0], {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expectedResult],
+ });
+
+ // Block it.
+ await QuickSuggest.blockedSuggestions.add(context.results[0].payload.url);
+
+ // Do another search. The result shouldn't be added.
+ await check_results({
+ context: createContext(suggestion.keywords[0], {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ await QuickSuggest.blockedSuggestions.clear();
+ }
+});
+
+// Tests blocking a real `UrlbarResult` whose URL has a timestamp template.
+add_tasks_with_rust(async function block_timestamp() {
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Do a search.
+ let context = createContext(TIMESTAMP_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ let controller = UrlbarTestUtils.newMockController();
+ await controller.startQuery(context);
+
+ // Should be one quick suggest result.
+ Assert.equal(context.results.length, 1, "One result returned");
+ let result = context.results[0];
+
+ QuickSuggestTestUtils.assertTimestampsReplaced(result, {
+ url: TIMESTAMP_SUGGESTION_URL,
+ sponsoredClickUrl: TIMESTAMP_SUGGESTION_CLICK_URL,
+ });
+
+ Assert.ok(result.payload.originalUrl, "The actual result has an originalUrl");
+ Assert.equal(
+ result.payload.originalUrl,
+ REMOTE_SETTINGS_RESULTS[4].url,
+ "The actual result's originalUrl should be the raw suggestion URL with a timestamp template"
+ );
+
+ // Block the result.
+ await QuickSuggest.blockedSuggestions.add(result.payload.originalUrl);
+
+ // Do another search. The result shouldn't be added.
+ await check_results({
+ context: createContext(TIMESTAMP_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+ await QuickSuggest.blockedSuggestions.clear();
+});
+
+// Makes sure remote settings data is fetched using the correct `type` based on
+// the value of the `quickSuggestRemoteSettingsDataType` Nimbus variable.
+add_task(
+ {
+ // Not supported by the Rust backend.
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function remoteSettingsDataType() {
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ await QuickSuggestTestUtils.forceSync();
+
+ for (let dataType of [undefined, "test-data-type"]) {
+ // Set up a mock Nimbus rollout with the data type.
+ let value = {};
+ if (dataType) {
+ value.quickSuggestRemoteSettingsDataType = dataType;
+ }
+ let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature(value);
+
+ // Make the result for test data type.
+ let expected = expectedSponsoredResult();
+ if (dataType) {
+ expected = JSON.parse(JSON.stringify(expected));
+ expected.payload.title = dataType;
+ }
+
+ // Re-sync.
+ await QuickSuggestTestUtils.forceSync();
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [expected],
+ });
+
+ await cleanUpNimbus();
+ }
+ }
+);
+
+add_tasks_with_rust(async function sponsoredPriority_normal() {
+ await doSponsoredPriorityTest({
+ searchWord: SPONSORED_SEARCH_STRING,
+ remoteSettingsData: [REMOTE_SETTINGS_RESULTS[0]],
+ expectedMatches: [expectedSponsoredPriorityResult()],
+ });
+});
+
+add_tasks_with_rust(async function sponsoredPriority_nonsponsoredSuggestion() {
+ // Not affect to except sponsored suggestion.
+ await doSponsoredPriorityTest({
+ searchWord: NONSPONSORED_SEARCH_STRING,
+ remoteSettingsData: [REMOTE_SETTINGS_RESULTS[1]],
+ expectedMatches: [expectedNonSponsoredResult()],
+ });
+});
+
+add_tasks_with_rust(async function sponsoredPriority_sponsoredIndex() {
+ await doSponsoredPriorityTest({
+ nimbusSettings: { quickSuggestSponsoredIndex: 2 },
+ searchWord: SPONSORED_SEARCH_STRING,
+ remoteSettingsData: [REMOTE_SETTINGS_RESULTS[0]],
+ expectedMatches: [expectedSponsoredPriorityResult()],
+ });
+});
+
+add_tasks_with_rust(async function sponsoredPriority_position() {
+ await doSponsoredPriorityTest({
+ nimbusSettings: { quickSuggestAllowPositionInSuggestions: true },
+ searchWord: SPONSORED_SEARCH_STRING,
+ remoteSettingsData: [
+ Object.assign({}, REMOTE_SETTINGS_RESULTS[0], { position: 2 }),
+ ],
+ expectedMatches: [expectedSponsoredPriorityResult()],
+ });
+});
+
+async function doSponsoredPriorityTest({
+ remoteSettingsConfig = {},
+ nimbusSettings = {},
+ searchWord,
+ remoteSettingsData,
+ expectedMatches,
+}) {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({
+ ...nimbusSettings,
+ quickSuggestSponsoredPriority: true,
+ });
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "data",
+ attachment: remoteSettingsData,
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig(remoteSettingsConfig);
+
+ await check_results({
+ context: createContext(searchWord, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: expectedMatches,
+ });
+
+ await cleanUpNimbusEnable();
+}
+
+// When a Suggest best match and a tab-to-search (TTS) are shown in the same
+// search, both will have a `suggestedIndex` value of 1. The TTS should appear
+// first.
+add_tasks_with_rust(async function tabToSearch() {
+ // We'll use a sponsored priority result as the best match result. Different
+ // types of Suggest results can appear as best matches, and they all should
+ // have the same behavior.
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ Services.prefs.setBoolPref(
+ "browser.urlbar.quicksuggest.sponsoredPriority",
+ true
+ );
+
+ // Disable tab-to-search onboarding results so we get a regular TTS result,
+ // which we can test a little more easily with `makeSearchResult()`.
+ UrlbarPrefs.set("tabToSearch.onboard.interactionsLeft", 0);
+
+ // Disable search suggestions so we don't need to expect them below.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ // Install a test engine. The main part of its domain name needs to match the
+ // best match result too so we can trigger both its TTS and the best match.
+ let engineURL = `https://foo.${SPONSORED_SEARCH_STRING}.com/`;
+ let extension = await SearchTestUtils.installSearchExtension(
+ {
+ name: "Test",
+ search_url: engineURL,
+ },
+ { skipUnload: true }
+ );
+ let engine = Services.search.getEngineByName("Test");
+
+ // Also need to add a visit to trigger TTS.
+ await PlacesTestUtils.addVisits(engineURL);
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [
+ // search heuristic
+ makeSearchResult(context, {
+ engineName: Services.search.defaultEngine.name,
+ engineIconUri: Services.search.defaultEngine.getIconURL(),
+ heuristic: true,
+ }),
+ // tab to search
+ makeSearchResult(context, {
+ engineName: engine.name,
+ engineIconUri: UrlbarUtils.ICON.SEARCH_GLASS,
+ uri: UrlbarUtils.stripPublicSuffixFromHost(engine.searchUrlDomain),
+ providesSearchMode: true,
+ query: "",
+ providerName: "TabToSearch",
+ satisfiesAutofillThreshold: true,
+ }),
+ // Suggest best match
+ expectedSponsoredPriorityResult(),
+ // visit
+ makeVisitResult(context, {
+ uri: engineURL,
+ title: `test visit for ${engineURL}`,
+ }),
+ ],
+ });
+
+ await cleanupPlaces();
+ await extension.unload();
+
+ UrlbarPrefs.clear("tabToSearch.onboard.interactionsLeft");
+ Services.prefs.clearUserPref("browser.search.suggest.enabled");
+ Services.prefs.clearUserPref("browser.urlbar.quicksuggest.sponsoredPriority");
+});
+
+// `suggestion.position` should be ignored when the suggestion is a best match.
+add_tasks_with_rust(async function position() {
+ // We'll use a sponsored priority result as the best match result. Different
+ // types of Suggest results can appear as best matches, and they all should
+ // have the same behavior.
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ Services.prefs.setBoolPref(
+ "browser.urlbar.quicksuggest.sponsoredPriority",
+ true
+ );
+
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ // Set the remote settings data with a suggestion containing a position.
+ UrlbarPrefs.set("quicksuggest.allowPositionInSuggestions", true);
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "data",
+ attachment: [
+ {
+ ...REMOTE_SETTINGS_RESULTS[0],
+ position: 9,
+ },
+ ],
+ },
+ ]);
+
+ let context = createContext(SPONSORED_SEARCH_STRING, {
+ isPrivate: false,
+ });
+
+ // Add some visits to fill up the view.
+ let maxResultCount = UrlbarPrefs.get("maxRichResults");
+ let visitResults = [];
+ for (let i = 0; i < maxResultCount; i++) {
+ let url = `http://example.com/${SPONSORED_SEARCH_STRING}-${i}`;
+ await PlacesTestUtils.addVisits(url);
+ visitResults.unshift(
+ makeVisitResult(context, {
+ uri: url,
+ title: `test visit for ${url}`,
+ })
+ );
+ }
+
+ // Do a search.
+ await check_results({
+ context,
+ matches: [
+ // search heuristic
+ makeSearchResult(context, {
+ engineName: Services.search.defaultEngine.name,
+ engineIconUri: Services.search.defaultEngine.getIconURL(),
+ heuristic: true,
+ }),
+ // best match whose backing suggestion has a `position`
+ expectedSponsoredPriorityResult(),
+ // visits
+ ...visitResults.slice(0, maxResultCount - 2),
+ ],
+ });
+
+ await cleanupPlaces();
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ]);
+
+ UrlbarPrefs.clear("quicksuggest.allowPositionInSuggestions");
+ Services.prefs.clearUserPref("browser.search.suggest.enabled");
+ Services.prefs.clearUserPref("browser.urlbar.quicksuggest.sponsoredPriority");
+});
+
+// The `Amp` and `Wikipedia` Rust providers should be passed to the Rust
+// component when querying depending on whether sponsored and non-sponsored
+// suggestions are enabled.
+add_task(async function rustProviders() {
+ await doRustProvidersTests({
+ searchString: SPONSORED_AND_NONSPONSORED_SEARCH_STRING,
+ tests: [
+ {
+ prefs: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ expectedUrls: [
+ "http://example.com/amp",
+ "http://example.com/wikipedia",
+ ],
+ },
+ {
+ prefs: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ expectedUrls: ["http://example.com/wikipedia"],
+ },
+ {
+ prefs: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ expectedUrls: ["http://example.com/amp"],
+ },
+ {
+ prefs: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ expectedUrls: [],
+ },
+ ],
+ });
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js
new file mode 100644
index 0000000000..c17f3f1655
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_addons.js
@@ -0,0 +1,558 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests addon quick suggest results.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs",
+});
+
+// TODO: Firefox no longer uses `rating` and `number_of_ratings` but they are
+// still present in Merino and RS suggestions, so they are included here for
+// greater accuracy. We should remove them from Merino, RS, and tests.
+const MERINO_SUGGESTIONS = [
+ {
+ provider: "amo",
+ icon: "icon",
+ url: "https://example.com/merino-addon",
+ title: "title",
+ description: "description",
+ is_top_pick: true,
+ custom_details: {
+ amo: {
+ rating: "5",
+ number_of_ratings: "1234567",
+ guid: "test@addon",
+ },
+ },
+ },
+];
+
+const REMOTE_SETTINGS_RESULTS = [
+ {
+ type: "amo-suggestions",
+ attachment: [
+ {
+ url: "https://example.com/first-addon",
+ guid: "first@addon",
+ icon: "https://example.com/first-addon.svg",
+ title: "First Addon",
+ rating: "4.7",
+ keywords: ["first", "1st", "two words", "a b c"],
+ description: "Description for the First Addon",
+ number_of_ratings: 1256,
+ score: 0.25,
+ },
+ {
+ url: "https://example.com/second-addon",
+ guid: "second@addon",
+ icon: "https://example.com/second-addon.svg",
+ title: "Second Addon",
+ rating: "1.7",
+ keywords: ["second", "2nd"],
+ description: "Description for the Second Addon",
+ number_of_ratings: 256,
+ score: 0.25,
+ },
+ {
+ url: "https://example.com/third-addon",
+ guid: "third@addon",
+ icon: "https://example.com/third-addon.svg",
+ title: "Third Addon",
+ rating: "3.7",
+ keywords: ["third", "3rd"],
+ description: "Description for the Third Addon",
+ number_of_ratings: 3,
+ score: 0.25,
+ },
+ {
+ url: "https://example.com/fourth-addon?utm_medium=aaa&utm_source=bbb",
+ guid: "fourth@addon",
+ icon: "https://example.com/fourth-addon.svg",
+ title: "Fourth Addon",
+ rating: "4.7",
+ keywords: ["fourth", "4th"],
+ description: "Description for the Fourth Addon",
+ number_of_ratings: 4,
+ score: 0.25,
+ },
+ ],
+ },
+];
+
+add_setup(async function init() {
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_RESULTS,
+ merinoSuggestions: MERINO_SUGGESTIONS,
+ prefs: [["suggest.quicksuggest.nonsponsored", true]],
+ });
+});
+
+add_task(async function telemetryType() {
+ Assert.equal(
+ QuickSuggest.getFeature("AddonSuggestions").getSuggestionTelemetryType({}),
+ "amo",
+ "Telemetry type should be 'amo'"
+ );
+});
+
+// When quick suggest prefs are disabled, addon suggestions should be disabled.
+add_tasks_with_rust(async function quickSuggestPrefsDisabled() {
+ let prefs = ["quicksuggest.enabled", "suggest.quicksuggest.nonsponsored"];
+ for (let pref of prefs) {
+ // Before disabling the pref, first make sure the suggestion is added.
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ suggestion: MERINO_SUGGESTIONS[0],
+ source: "merino",
+ }),
+ ],
+ });
+
+ // Now disable the pref.
+ UrlbarPrefs.set(pref, false);
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ UrlbarPrefs.set(pref, true);
+ await QuickSuggestTestUtils.forceSync();
+ }
+});
+
+// When addon suggestions specific preference is disabled, addon suggestions
+// should not be added.
+add_tasks_with_rust(async function addonSuggestionsSpecificPrefDisabled() {
+ const prefs = ["suggest.addons", "addons.featureGate"];
+ for (const pref of prefs) {
+ // First make sure the suggestion is added.
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ suggestion: MERINO_SUGGESTIONS[0],
+ source: "merino",
+ }),
+ ],
+ });
+
+ // Now disable the pref.
+ UrlbarPrefs.set(pref, false);
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Revert.
+ UrlbarPrefs.clear(pref);
+ await QuickSuggestTestUtils.forceSync();
+ }
+});
+
+// Check wheather the addon suggestions will be shown by the setup of Nimbus
+// variable.
+add_tasks_with_rust(async function nimbus() {
+ // Disable the fature gate.
+ UrlbarPrefs.set("addons.featureGate", false);
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Enable by Nimbus.
+ const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({
+ addonsFeatureGate: true,
+ });
+ await QuickSuggestTestUtils.forceSync();
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ suggestion: MERINO_SUGGESTIONS[0],
+ source: "merino",
+ }),
+ ],
+ });
+ await cleanUpNimbusEnable();
+
+ // Enable locally.
+ UrlbarPrefs.set("addons.featureGate", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Disable by Nimbus.
+ const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({
+ addonsFeatureGate: false,
+ });
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+ await cleanUpNimbusDisable();
+
+ // Revert.
+ UrlbarPrefs.clear("addons.featureGate");
+ await QuickSuggestTestUtils.forceSync();
+});
+
+add_tasks_with_rust(async function hideIfAlreadyInstalled() {
+ // Show suggestion.
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ suggestion: MERINO_SUGGESTIONS[0],
+ source: "merino",
+ }),
+ ],
+ });
+
+ // Install an addon for the suggestion.
+ const xpi = ExtensionTestCommon.generateXPI({
+ manifest: {
+ browser_specific_settings: {
+ gecko: { id: "test@addon" },
+ },
+ },
+ });
+ const addon = await AddonManager.installTemporaryAddon(xpi);
+
+ // Show suggestion for the addon installed.
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ await addon.uninstall();
+ xpi.remove(false);
+});
+
+add_tasks_with_rust(async function remoteSettings() {
+ const testCases = [
+ {
+ input: "f",
+ expected: null,
+ },
+ {
+ input: "fi",
+ expected: null,
+ },
+ {
+ input: "fir",
+ expected: null,
+ },
+ {
+ input: "firs",
+ expected: null,
+ },
+ {
+ input: "first",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "1st",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "t",
+ expected: null,
+ },
+ {
+ input: "tw",
+ expected: null,
+ },
+ {
+ input: "two",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "two ",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "two w",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "two wo",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "two wor",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "two word",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "two words",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "a",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "a ",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "a b",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "a b ",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "a b c",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "second",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "2nd",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[1],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "third",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "3rd",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[2],
+ source: "remote-settings",
+ }),
+ },
+ {
+ input: "fourth",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[3],
+ source: "remote-settings",
+ setUtmParams: false,
+ }),
+ },
+ {
+ input: "FoUrTh",
+ expected: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[3],
+ source: "remote-settings",
+ setUtmParams: false,
+ }),
+ },
+ ];
+
+ // Disable Merino so we trigger only remote settings suggestions.
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", false);
+
+ for (let { input, expected } of testCases) {
+ await check_results({
+ context: createContext(input, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: expected ? [expected] : [],
+ });
+ }
+
+ UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true);
+});
+
+add_task(async function merinoIsTopPick() {
+ const suggestion = JSON.parse(JSON.stringify(MERINO_SUGGESTIONS[0]));
+
+ // is_top_pick is specified as false.
+ suggestion.is_top_pick = false;
+ MerinoTestUtils.server.response.body.suggestions = [suggestion];
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ suggestion,
+ source: "merino",
+ }),
+ ],
+ });
+
+ // is_top_pick is undefined.
+ delete suggestion.is_top_pick;
+ MerinoTestUtils.server.response.body.suggestions = [suggestion];
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ suggestion,
+ source: "merino",
+ }),
+ ],
+ });
+});
+
+// Tests the "show less frequently" behavior.
+add_tasks_with_rust(async function showLessFrequently() {
+ await doShowLessFrequentlyTests({
+ feature: QuickSuggest.getFeature("AddonSuggestions"),
+ showLessFrequentlyCountPref: "addons.showLessFrequentlyCount",
+ nimbusCapVariable: "addonsShowLessFrequentlyCap",
+ expectedResult: makeExpectedResult({
+ suggestion: REMOTE_SETTINGS_RESULTS[0].attachment[0],
+ source: "remote-settings",
+ }),
+ keyword: "two words",
+ });
+});
+
+// The `Amo` Rust provider should be passed to the Rust component when querying
+// depending on whether addon suggestions are enabled.
+add_task(async function rustProviders() {
+ await doRustProvidersTests({
+ searchString: "first",
+ tests: [
+ {
+ prefs: {
+ "suggest.addons": true,
+ },
+ expectedUrls: ["https://example.com/first-addon"],
+ },
+ {
+ prefs: {
+ "suggest.addons": false,
+ },
+ expectedUrls: [],
+ },
+ ],
+ });
+
+ UrlbarPrefs.clear("suggest.addons");
+ await QuickSuggestTestUtils.forceSync();
+});
+
+function makeExpectedResult({ suggestion, source, setUtmParams = true }) {
+ if (
+ source == "remote-settings" &&
+ UrlbarPrefs.get("quicksuggest.rustEnabled")
+ ) {
+ source = "rust";
+ }
+
+ let provider;
+ switch (source) {
+ case "remote-settings":
+ provider = "AddonSuggestions";
+ break;
+ case "rust":
+ provider = "Amo";
+ break;
+ case "merino":
+ provider = "amo";
+ break;
+ }
+
+ return makeAmoResult({
+ source,
+ provider,
+ setUtmParams,
+ title: suggestion.title,
+ description: suggestion.description,
+ url: suggestion.url,
+ originalUrl: suggestion.url,
+ icon: suggestion.icon,
+ });
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js
new file mode 100644
index 0000000000..a9f339c324
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_dynamicWikipedia.js
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests dynamic Wikipedia quick suggest results.
+
+"use strict";
+
+const MERINO_SUGGESTIONS = [
+ {
+ title: "title",
+ url: "url",
+ is_sponsored: false,
+ score: 0.23,
+ description: "description",
+ icon: "icon",
+ full_keyword: "full_keyword",
+ advertiser: "dynamic-wikipedia",
+ block_id: 0,
+ impression_url: "impression_url",
+ click_url: "click_url",
+ provider: "wikipedia",
+ },
+];
+
+add_setup(async function init() {
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ merinoSuggestions: MERINO_SUGGESTIONS,
+ prefs: [["suggest.quicksuggest.nonsponsored", true]],
+ });
+});
+
+// When non-sponsored suggestions are disabled, dynamic Wikipedia suggestions
+// should be disabled.
+add_task(async function nonsponsoredDisabled() {
+ // Disable sponsored suggestions. Dynamic Wikipedia suggestions are
+ // non-sponsored, so doing this should not prevent them from being enabled.
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+
+ // First make sure the suggestion is added when non-sponsored suggestions are
+ // enabled.
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeExpectedResult()],
+ });
+
+ // Now disable them.
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ await check_results({
+ context: createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.clear("suggest.quicksuggest.sponsored");
+});
+
+add_task(async function mixedCaseQuery() {
+ await check_results({
+ context: createContext("TeSt", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeExpectedResult()],
+ });
+});
+
+function makeExpectedResult() {
+ return {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ suggestedIndex: -1,
+ payload: {
+ telemetryType: "wikipedia",
+ title: "title",
+ url: "url",
+ displayUrl: "url",
+ isSponsored: false,
+ icon: "icon",
+ qsSuggestion: "full_keyword",
+ source: "merino",
+ provider: "wikipedia",
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ },
+ };
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js
new file mode 100644
index 0000000000..1c00cb5320
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_impressionCaps.js
@@ -0,0 +1,3907 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests impression frequency capping for quick suggest results.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const REMOTE_SETTINGS_RESULTS = [
+ {
+ id: 1,
+ url: "http://example.com/sponsored",
+ title: "Sponsored suggestion",
+ keywords: ["sponsored"],
+ click_url: "http://example.com/click",
+ impression_url: "http://example.com/impression",
+ advertiser: "TestAdvertiser",
+ iab_category: "22 - Shopping",
+ },
+ {
+ id: 2,
+ url: "http://example.com/nonsponsored",
+ title: "Non-sponsored suggestion",
+ keywords: ["nonsponsored"],
+ click_url: "http://example.com/click",
+ impression_url: "http://example.com/impression",
+ advertiser: "TestAdvertiser",
+ iab_category: "5 - Education",
+ },
+];
+
+const EXPECTED_SPONSORED_URLBAR_RESULT = {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ telemetryType: "adm_sponsored",
+ url: "http://example.com/sponsored",
+ originalUrl: "http://example.com/sponsored",
+ displayUrl: "http://example.com/sponsored",
+ title: "Sponsored suggestion",
+ qsSuggestion: "sponsored",
+ icon: null,
+ isSponsored: true,
+ sponsoredImpressionUrl: "http://example.com/impression",
+ sponsoredClickUrl: "http://example.com/click",
+ sponsoredBlockId: 1,
+ sponsoredAdvertiser: "TestAdvertiser",
+ sponsoredIabCategory: "22 - Shopping",
+ descriptionL10n: { id: "urlbar-result-action-sponsored" },
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ source: "remote-settings",
+ provider: "AdmWikipedia",
+ },
+};
+
+const EXPECTED_NONSPONSORED_URLBAR_RESULT = {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ telemetryType: "adm_nonsponsored",
+ url: "http://example.com/nonsponsored",
+ originalUrl: "http://example.com/nonsponsored",
+ displayUrl: "http://example.com/nonsponsored",
+ title: "Non-sponsored suggestion",
+ qsSuggestion: "nonsponsored",
+ icon: null,
+ isSponsored: false,
+ sponsoredImpressionUrl: "http://example.com/impression",
+ sponsoredClickUrl: "http://example.com/click",
+ sponsoredBlockId: 2,
+ sponsoredAdvertiser: "TestAdvertiser",
+ sponsoredIabCategory: "5 - Education",
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ source: "remote-settings",
+ provider: "AdmWikipedia",
+ },
+};
+
+let gSandbox;
+let gDateNowStub;
+let gStartupDateMsStub;
+
+add_setup(async () => {
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ prefs: [
+ ["quicksuggest.impressionCaps.sponsoredEnabled", true],
+ ["quicksuggest.impressionCaps.nonSponsoredEnabled", true],
+ ["suggest.quicksuggest.nonsponsored", true],
+ ["suggest.quicksuggest.sponsored", true],
+ ],
+ });
+
+ // Set up a sinon stub for the `Date.now()` implementation inside of
+ // UrlbarProviderQuickSuggest. This lets us test searches performed at
+ // specific times. See `doTimedCallbacks()` for more info.
+ gSandbox = sinon.createSandbox();
+ gDateNowStub = gSandbox.stub(
+ Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date,
+ "now"
+ );
+
+ // Set up a sinon stub for `UrlbarProviderQuickSuggest._getStartupDateMs()` to
+ // let the test override the startup date.
+ gStartupDateMsStub = gSandbox.stub(
+ QuickSuggest.impressionCaps,
+ "_getStartupDateMs"
+ );
+ gStartupDateMsStub.returns(0);
+});
+
+// Tests a single interval.
+add_task(async function oneInterval() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedSearches("sponsored", {
+ 0: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "3",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ 1: {
+ results: [[]],
+ },
+ 2: {
+ results: [[]],
+ },
+ 3: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "1",
+ startDate: "3000",
+ impressionDate: "3000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ 4: {
+ results: [[]],
+ },
+ 5: {
+ results: [[]],
+ },
+ });
+ },
+ });
+});
+
+// Tests multiple intervals.
+add_task(async function multipleIntervals() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [
+ { interval_s: 1, max_count: 1 },
+ { interval_s: 5, max_count: 3 },
+ { interval_s: 10, max_count: 5 },
+ ],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedSearches("sponsored", {
+ // 0s: 1 new impression; 1 impression total
+ 0: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 1s: 1 new impression; 2 impressions total
+ 1: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 2s: 1 new impression; 3 impressions total
+ 2: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 5, max_count: 3
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 3s: no new impressions; 3 impressions total
+ 3: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 4s: no new impressions; 3 impressions total
+ 4: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "4000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "3000",
+ impressionDate: "2000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 5s: 1 new impression; 4 impressions total
+ 5: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "4000",
+ impressionDate: "2000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 5, max_count: 3
+ {
+ object: "reset",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "5000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 6s: 1 new impression; 5 impressions total
+ 6: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "6000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "5000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "6000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "6000",
+ impressionDate: "6000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 10, max_count: 5
+ {
+ object: "hit",
+ extra: {
+ eventDate: "6000",
+ intervalSeconds: "10",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "6000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 7s: no new impressions; 5 impressions total
+ 7: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "7000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "6000",
+ impressionDate: "6000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 8s: no new impressions; 5 impressions total
+ 8: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "8000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "7000",
+ impressionDate: "6000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 9s: no new impressions; 5 impressions total
+ 9: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "9000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "8000",
+ impressionDate: "6000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 10s: 1 new impression; 6 impressions total
+ 10: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "9000",
+ impressionDate: "6000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 5, max_count: 3
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "5000",
+ impressionDate: "6000",
+ count: "2",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 10, max_count: 5
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "10",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "6000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "10000",
+ impressionDate: "10000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 11s: 1 new impression; 7 impressions total
+ 11: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "11000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "10000",
+ impressionDate: "10000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "11000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "11000",
+ impressionDate: "11000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 12s: 1 new impression; 8 impressions total
+ 12: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "12000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "11000",
+ impressionDate: "11000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "12000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "12000",
+ impressionDate: "12000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 5, max_count: 3
+ {
+ object: "hit",
+ extra: {
+ eventDate: "12000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "10000",
+ impressionDate: "12000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 13s: no new impressions; 8 impressions total
+ 13: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "13000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "12000",
+ impressionDate: "12000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 14s: no new impressions; 8 impressions total
+ 14: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "14000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "13000",
+ impressionDate: "12000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 15s: 1 new impression; 9 impressions total
+ 15: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "15000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "14000",
+ impressionDate: "12000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 5, max_count: 3
+ {
+ object: "reset",
+ extra: {
+ eventDate: "15000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "10000",
+ impressionDate: "12000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "15000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "15000",
+ impressionDate: "15000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 16s: 1 new impression; 10 impressions total
+ 16: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "16000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "15000",
+ impressionDate: "15000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "16000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "16000",
+ impressionDate: "16000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 10, max_count: 5
+ {
+ object: "hit",
+ extra: {
+ eventDate: "16000",
+ intervalSeconds: "10",
+ maxCount: "5",
+ startDate: "10000",
+ impressionDate: "16000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 17s: no new impressions; 10 impressions total
+ 17: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "17000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "16000",
+ impressionDate: "16000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 18s: no new impressions; 10 impressions total
+ 18: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "18000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "17000",
+ impressionDate: "16000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 19s: no new impressions; 10 impressions total
+ 19: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "19000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "18000",
+ impressionDate: "16000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 20s: 1 new impression; 11 impressions total
+ 20: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "20000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "19000",
+ impressionDate: "16000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 5, max_count: 3
+ {
+ object: "reset",
+ extra: {
+ eventDate: "20000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "15000",
+ impressionDate: "16000",
+ count: "2",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 10, max_count: 5
+ {
+ object: "reset",
+ extra: {
+ eventDate: "20000",
+ intervalSeconds: "10",
+ maxCount: "5",
+ startDate: "10000",
+ impressionDate: "16000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "20000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "20000",
+ impressionDate: "20000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ });
+ },
+ });
+});
+
+// Tests a lifetime cap.
+add_task(async function lifetime() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 3,
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedSearches("sponsored", {
+ 0: {
+ results: [
+ [EXPECTED_SPONSORED_URLBAR_RESULT],
+ [EXPECTED_SPONSORED_URLBAR_RESULT],
+ [EXPECTED_SPONSORED_URLBAR_RESULT],
+ [],
+ ],
+ telemetry: {
+ events: [
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "Infinity",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ 1: {
+ results: [[]],
+ },
+ });
+ },
+ });
+});
+
+// Tests one interval and a lifetime cap together.
+add_task(async function intervalAndLifetime() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 3,
+ custom: [{ interval_s: 1, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedSearches("sponsored", {
+ // 0s: 1 new impression; 1 impression total
+ 0: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 1s: 1 new impression; 2 impressions total
+ 1: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 2s: 1 new impression; 3 impressions total
+ 2: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: Infinity, max_count: 3
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "Infinity",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ 3: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ });
+ },
+ });
+});
+
+// Tests multiple intervals and a lifetime cap together.
+add_task(async function multipleIntervalsAndLifetime() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 4,
+ custom: [
+ { interval_s: 1, max_count: 1 },
+ { interval_s: 5, max_count: 3 },
+ ],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedSearches("sponsored", {
+ // 0s: 1 new impression; 1 impression total
+ 0: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 1s: 1 new impression; 2 impressions total
+ 1: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 2s: 1 new impression; 3 impressions total
+ 2: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 5, max_count: 3
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 3s: no new impressions; 3 impressions total
+ 3: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 4s: no new impressions; 3 impressions total
+ 4: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "4000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "3000",
+ impressionDate: "2000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 5s: 1 new impression; 4 impressions total
+ 5: {
+ results: [[EXPECTED_SPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "4000",
+ impressionDate: "2000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 5, max_count: 3
+ {
+ object: "reset",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "5000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: Infinity, max_count: 4
+ {
+ object: "hit",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "Infinity",
+ maxCount: "4",
+ startDate: "0",
+ impressionDate: "5000",
+ count: "4",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 6s: no new impressions; 4 impressions total
+ 6: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "6000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "5000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 7s: no new impressions; 4 impressions total
+ 7: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "7000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "6000",
+ impressionDate: "5000",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ });
+ },
+ });
+});
+
+// Smoke test for non-sponsored caps. Most tasks use sponsored results and caps,
+// but sponsored and non-sponsored should behave the same since they use the
+// same code paths.
+add_task(async function nonsponsored() {
+ await doTest({
+ config: {
+ impression_caps: {
+ nonsponsored: {
+ lifetime: 4,
+ custom: [
+ { interval_s: 1, max_count: 1 },
+ { interval_s: 5, max_count: 3 },
+ ],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedSearches("nonsponsored", {
+ // 0s: 1 new impression; 1 impression total
+ 0: {
+ results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 1s: 1 new impression; 2 impressions total
+ 1: {
+ results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 2s: 1 new impression; 3 impressions total
+ 2: {
+ results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 5, max_count: 3
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "3",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 3s: no new impressions; 3 impressions total
+ 3: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 4s: no new impressions; 3 impressions total
+ 4: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "4000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "3000",
+ impressionDate: "2000",
+ count: "0",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 5s: 1 new impression; 4 impressions total
+ 5: {
+ results: [[EXPECTED_NONSPONSORED_URLBAR_RESULT], []],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "4000",
+ impressionDate: "2000",
+ count: "0",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ // reset: interval_s: 5, max_count: 3
+ {
+ object: "reset",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "3",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: 1, max_count: 1
+ {
+ object: "hit",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "5000",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ // hit: interval_s: Infinity, max_count: 4
+ {
+ object: "hit",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "Infinity",
+ maxCount: "4",
+ startDate: "0",
+ impressionDate: "5000",
+ count: "4",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 6s: no new impressions; 4 impressions total
+ 6: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "6000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "5000",
+ count: "1",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ // 7s: no new impressions; 4 impressions total
+ 7: {
+ results: [[]],
+ telemetry: {
+ events: [
+ // reset: interval_s: 1, max_count: 1
+ {
+ object: "reset",
+ extra: {
+ eventDate: "7000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "6000",
+ impressionDate: "5000",
+ count: "0",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ],
+ },
+ },
+ });
+ },
+ });
+});
+
+// Smoke test for sponsored and non-sponsored caps together. Most tasks use only
+// sponsored results and caps, but sponsored and non-sponsored should behave the
+// same since they use the same code paths.
+add_task(async function sponsoredAndNonsponsored() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 2,
+ },
+ nonsponsored: {
+ lifetime: 3,
+ },
+ },
+ },
+ callback: async () => {
+ // 1st searches
+ await checkSearch({
+ name: "sponsored 1",
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "nonsponsored 1",
+ searchString: "nonsponsored",
+ expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT],
+ });
+ await checkTelemetryEvents([]);
+
+ // 2nd searches
+ await checkSearch({
+ name: "sponsored 2",
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "nonsponsored 2",
+ searchString: "nonsponsored",
+ expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "Infinity",
+ maxCount: "2",
+ startDate: "0",
+ impressionDate: "0",
+ count: "2",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+
+ // 3rd searches
+ await checkSearch({
+ name: "sponsored 3",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkSearch({
+ name: "nonsponsored 3",
+ searchString: "nonsponsored",
+ expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "Infinity",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+
+ // 4th searches
+ await checkSearch({
+ name: "sponsored 4",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkSearch({
+ name: "nonsponsored 4",
+ searchString: "nonsponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ });
+});
+
+// Tests with an empty config to make sure results are not capped.
+add_task(async function emptyConfig() {
+ await doTest({
+ config: {},
+ callback: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: "sponsored " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "nonsponsored " + i,
+ searchString: "nonsponsored",
+ expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([]);
+ },
+ });
+});
+
+// Tests with sponsored caps disabled. Non-sponsored should still be capped.
+add_task(async function sponsoredCapsDisabled() {
+ UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", false);
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 0,
+ },
+ nonsponsored: {
+ lifetime: 3,
+ },
+ },
+ },
+ callback: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "sponsored " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "nonsponsored " + i,
+ searchString: "nonsponsored",
+ expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "Infinity",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "nonsponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+
+ await checkSearch({
+ name: "sponsored additional",
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "nonsponsored additional",
+ searchString: "nonsponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ });
+ UrlbarPrefs.set("quicksuggest.impressionCaps.sponsoredEnabled", true);
+});
+
+// Tests with non-sponsored caps disabled. Sponsored should still be capped.
+add_task(async function nonsponsoredCapsDisabled() {
+ UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", false);
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 3,
+ },
+ nonsponsored: {
+ lifetime: 0,
+ },
+ },
+ },
+ callback: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "sponsored " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "nonsponsored " + i,
+ searchString: "nonsponsored",
+ expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "Infinity",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+
+ await checkSearch({
+ name: "sponsored additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkSearch({
+ name: "nonsponsored additional",
+ searchString: "nonsponsored",
+ expectedResults: [EXPECTED_NONSPONSORED_URLBAR_RESULT],
+ });
+ await checkTelemetryEvents([]);
+ },
+ });
+ UrlbarPrefs.set("quicksuggest.impressionCaps.nonSponsoredEnabled", true);
+});
+
+// Tests a config change: 1 interval -> same interval with lower cap, with the
+// old cap already reached
+add_task(async function configChange_sameIntervalLowerCap_1() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "0s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "3",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 1 }],
+ },
+ },
+ });
+ },
+ 1: async () => {
+ await checkSearch({
+ name: "1s",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ 3: async () => {
+ await checkSearch({
+ name: "3s 0",
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "3s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "1",
+ startDate: "3000",
+ impressionDate: "3000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: 1 interval -> same interval with lower cap, with the
+// old cap not reached
+add_task(async function configChange_sameIntervalLowerCap_2() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 1 }],
+ },
+ },
+ });
+ },
+ 1: async () => {
+ await checkSearch({
+ name: "1s",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ },
+ 3: async () => {
+ await checkSearch({
+ name: "3s 0",
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "3s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "2",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "1",
+ startDate: "3000",
+ impressionDate: "3000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: 1 interval -> same interval with higher cap
+add_task(async function configChange_sameIntervalHigherCap() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "0s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "3",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 5 }],
+ },
+ },
+ });
+ },
+ 1: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: "1s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "1s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "3",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "1000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ 3: async () => {
+ for (let i = 0; i < 5; i++) {
+ await checkSearch({
+ name: "3s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "3s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "1000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "3000",
+ intervalSeconds: "3",
+ maxCount: "5",
+ startDate: "3000",
+ impressionDate: "3000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: 1 interval -> 2 new intervals with higher timeouts.
+// Impression counts for the old interval should contribute to the new
+// intervals.
+add_task(async function configChange_1IntervalTo2NewIntervalsHigher() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "3",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ custom: [
+ { interval_s: 5, max_count: 3 },
+ { interval_s: 10, max_count: 5 },
+ ],
+ },
+ },
+ });
+ },
+ 3: async () => {
+ await checkSearch({
+ name: "3s",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ 4: async () => {
+ await checkSearch({
+ name: "4s",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ 5: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: "5s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "5s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "5000",
+ intervalSeconds: "10",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "5000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: 2 intervals -> 1 new interval with higher timeout.
+// Impression counts for the old intervals should contribute to the new
+// interval.
+add_task(async function configChange_2IntervalsTo1NewIntervalHigher() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [
+ { interval_s: 2, max_count: 2 },
+ { interval_s: 4, max_count: 4 },
+ ],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "2",
+ maxCount: "2",
+ startDate: "0",
+ impressionDate: "0",
+ count: "2",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ 2: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: "2s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "2",
+ maxCount: "2",
+ startDate: "0",
+ impressionDate: "0",
+ count: "2",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "2",
+ maxCount: "2",
+ startDate: "2000",
+ impressionDate: "2000",
+ count: "2",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "2000",
+ intervalSeconds: "4",
+ maxCount: "4",
+ startDate: "0",
+ impressionDate: "2000",
+ count: "4",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 6, max_count: 5 }],
+ },
+ },
+ });
+ },
+ 4: async () => {
+ await checkSearch({
+ name: "4s 0",
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ await checkSearch({
+ name: "4s 1",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "4000",
+ intervalSeconds: "6",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "4000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ 5: async () => {
+ await checkSearch({
+ name: "5s",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ 6: async () => {
+ for (let i = 0; i < 5; i++) {
+ await checkSearch({
+ name: "6s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "6s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "6000",
+ intervalSeconds: "6",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "4000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ {
+ object: "hit",
+ extra: {
+ eventDate: "6000",
+ intervalSeconds: "6",
+ maxCount: "5",
+ startDate: "6000",
+ impressionDate: "6000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: 1 interval -> 1 new interval with lower timeout.
+// Impression counts for the old interval should not contribute to the new
+// interval since the new interval has a lower timeout.
+add_task(async function configChange_1IntervalTo1NewIntervalLower() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 5, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "5",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 3 }],
+ },
+ },
+ });
+ },
+ 1: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "3s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "3s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "3",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "1000",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: 1 interval -> lifetime.
+// Impression counts for the old interval should contribute to the new lifetime
+// cap.
+add_task(async function configChange_1IntervalToLifetime() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 3, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "3",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ lifetime: 3,
+ },
+ },
+ });
+ },
+ 3: async () => {
+ await checkSearch({
+ name: "3s",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: lifetime cap -> higher lifetime cap
+add_task(async function configChange_lifetimeCapHigher() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 3,
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "0s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "Infinity",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ lifetime: 5,
+ },
+ },
+ });
+ },
+ 1: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: "1s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "1s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "Infinity",
+ maxCount: "5",
+ startDate: "0",
+ impressionDate: "1000",
+ count: "5",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+});
+
+// Tests a config change: lifetime cap -> lower lifetime cap
+add_task(async function configChange_lifetimeCapLower() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 3,
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ 0: async () => {
+ for (let i = 0; i < 3; i++) {
+ await checkSearch({
+ name: "0s " + i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+ await checkSearch({
+ name: "0s additional",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([
+ {
+ object: "hit",
+ extra: {
+ eventDate: "0",
+ intervalSeconds: "Infinity",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "3",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ await QuickSuggestTestUtils.setConfig({
+ impression_caps: {
+ sponsored: {
+ lifetime: 1,
+ },
+ },
+ });
+ },
+ 1: async () => {
+ await checkSearch({
+ name: "1s",
+ searchString: "sponsored",
+ expectedResults: [],
+ });
+ await checkTelemetryEvents([]);
+ },
+ });
+ },
+ });
+});
+
+// Makes sure stats are serialized to and from the pref correctly.
+add_task(async function prefSync() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 5,
+ custom: [
+ { interval_s: 3, max_count: 2 },
+ { interval_s: 5, max_count: 4 },
+ ],
+ },
+ },
+ },
+ callback: async () => {
+ for (let i = 0; i < 2; i++) {
+ await checkSearch({
+ name: i,
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ }
+
+ let json = UrlbarPrefs.get("quicksuggest.impressionCaps.stats");
+ Assert.ok(json, "JSON is non-empty");
+ Assert.deepEqual(
+ JSON.parse(json),
+ {
+ sponsored: [
+ {
+ intervalSeconds: 3,
+ count: 2,
+ maxCount: 2,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: 5,
+ count: 2,
+ maxCount: 4,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: null,
+ count: 2,
+ maxCount: 5,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ ],
+ },
+ "JSON is correct"
+ );
+
+ QuickSuggest.impressionCaps._test_reloadStats();
+ Assert.deepEqual(
+ QuickSuggest.impressionCaps._test_stats,
+ {
+ sponsored: [
+ {
+ intervalSeconds: 3,
+ count: 2,
+ maxCount: 2,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: 5,
+ count: 2,
+ maxCount: 4,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: Infinity,
+ count: 2,
+ maxCount: 5,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ ],
+ },
+ "Impression stats were properly restored from the pref"
+ );
+ },
+ });
+});
+
+// Tests direct changes to the stats pref.
+add_task(async function prefDirectlyChanged() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ lifetime: 5,
+ custom: [{ interval_s: 3, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ let expectedStats = {
+ sponsored: [
+ {
+ intervalSeconds: 3,
+ count: 0,
+ maxCount: 3,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: Infinity,
+ count: 0,
+ maxCount: 5,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ ],
+ };
+
+ UrlbarPrefs.set("quicksuggest.impressionCaps.stats", "bogus");
+ Assert.deepEqual(
+ QuickSuggest.impressionCaps._test_stats,
+ expectedStats,
+ "Expected stats for 'bogus'"
+ );
+
+ UrlbarPrefs.set("quicksuggest.impressionCaps.stats", JSON.stringify({}));
+ Assert.deepEqual(
+ QuickSuggest.impressionCaps._test_stats,
+ expectedStats,
+ "Expected stats for {}"
+ );
+
+ UrlbarPrefs.set(
+ "quicksuggest.impressionCaps.stats",
+ JSON.stringify({ sponsored: "bogus" })
+ );
+ Assert.deepEqual(
+ QuickSuggest.impressionCaps._test_stats,
+ expectedStats,
+ "Expected stats for { sponsored: 'bogus' }"
+ );
+
+ UrlbarPrefs.set(
+ "quicksuggest.impressionCaps.stats",
+ JSON.stringify({
+ sponsored: [
+ {
+ intervalSeconds: 3,
+ count: 0,
+ maxCount: 3,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: "bogus",
+ count: 0,
+ maxCount: 99,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: Infinity,
+ count: 0,
+ maxCount: 5,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ ],
+ })
+ );
+ Assert.deepEqual(
+ QuickSuggest.impressionCaps._test_stats,
+ expectedStats,
+ "Expected stats with intervalSeconds: 'bogus'"
+ );
+
+ UrlbarPrefs.set(
+ "quicksuggest.impressionCaps.stats",
+ JSON.stringify({
+ sponsored: [
+ {
+ intervalSeconds: 3,
+ count: 0,
+ maxCount: 123,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ {
+ intervalSeconds: Infinity,
+ count: 0,
+ maxCount: 456,
+ startDateMs: 0,
+ impressionDateMs: 0,
+ },
+ ],
+ })
+ );
+ Assert.deepEqual(
+ QuickSuggest.impressionCaps._test_stats,
+ expectedStats,
+ "Expected stats with `maxCount` values different from caps"
+ );
+
+ let stats = {
+ sponsored: [
+ {
+ intervalSeconds: 3,
+ count: 1,
+ maxCount: 3,
+ startDateMs: 99,
+ impressionDateMs: 99,
+ },
+ {
+ intervalSeconds: Infinity,
+ count: 7,
+ maxCount: 5,
+ startDateMs: 1337,
+ impressionDateMs: 1337,
+ },
+ ],
+ };
+ UrlbarPrefs.set(
+ "quicksuggest.impressionCaps.stats",
+ JSON.stringify(stats)
+ );
+ Assert.deepEqual(
+ QuickSuggest.impressionCaps._test_stats,
+ stats,
+ "Expected stats with valid JSON"
+ );
+ },
+ });
+});
+
+// Tests multiple interval periods where the cap is not hit. Telemetry should be
+// recorded for these periods.
+add_task(async function intervalsElapsedButCapNotHit() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 1, max_count: 3 }],
+ },
+ },
+ },
+ callback: async () => {
+ await doTimedCallbacks({
+ // 1s
+ 1: async () => {
+ await checkSearch({
+ name: "1s",
+ searchString: "sponsored",
+ expectedResults: [EXPECTED_SPONSORED_URLBAR_RESULT],
+ });
+ },
+ // 10s
+ 10: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ let expectedEvents = [
+ // 1s: reset with count = 0
+ {
+ object: "reset",
+ extra: {
+ eventDate: "1000",
+ intervalSeconds: "1",
+ maxCount: "3",
+ startDate: "0",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ // 2-10s: reset with count = 1, eventCount = 9
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "1",
+ maxCount: "3",
+ startDate: "1000",
+ impressionDate: "1000",
+ count: "1",
+ type: "sponsored",
+ eventCount: "9",
+ },
+ },
+ ];
+ await checkTelemetryEvents(expectedEvents);
+ },
+ });
+ },
+ });
+});
+
+// Simulates reset events across a restart with the following:
+//
+// S S R
+// >----|----|----|----|----|----|----|----|----|----|
+// 0s 1 2 3 4 5 6 7 8 9 10
+//
+// 1. Startup at 0s
+// 2. Caps and stats initialized with interval_s: 1
+// 3. Startup at 4.5s
+// 4. Reset triggered at 10s
+//
+// Expected:
+// At 10s: 6 batched resets for periods starting at 4s
+add_task(async function restart_1() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 1, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ gStartupDateMsStub.returns(4500);
+ await doTimedCallbacks({
+ // 10s: 6 batched resets for periods starting at 4s
+ 10: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "4000",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "6",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+ gStartupDateMsStub.returns(0);
+});
+
+// Simulates reset events across a restart with the following:
+//
+// S S R
+// >----|----|----|----|----|----|----|----|----|----|
+// 0s 1 2 3 4 5 6 7 8 9 10
+//
+// 1. Startup at 0s
+// 2. Caps and stats initialized with interval_s: 1
+// 3. Startup at 5s
+// 4. Reset triggered at 10s
+//
+// Expected:
+// At 10s: 5 batched resets for periods starting at 5s
+add_task(async function restart_2() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 1, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ gStartupDateMsStub.returns(5000);
+ await doTimedCallbacks({
+ // 10s: 5 batched resets for periods starting at 5s
+ 10: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "5",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+ gStartupDateMsStub.returns(0);
+});
+
+// Simulates reset events across a restart with the following:
+//
+// S S R
+// >----|----|----|----|----|----|----|----|----|----|
+// 0s 1 2 3 4 5 6 7 8 9 10
+//
+// 1. Startup at 0s
+// 2. Caps and stats initialized with interval_s: 1
+// 3. Startup at 5.5s
+// 4. Reset triggered at 10s
+//
+// Expected:
+// At 10s: 5 batched resets for periods starting at 5s
+add_task(async function restart_3() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 1, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ gStartupDateMsStub.returns(5500);
+ await doTimedCallbacks({
+ // 10s: 5 batched resets for periods starting at 5s
+ 10: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "5000",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "5",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+ gStartupDateMsStub.returns(0);
+});
+
+// Simulates reset events across a restart with the following:
+//
+// S S RR RR
+// >---------|---------|
+// 0s 10 20
+//
+// 1. Startup at 0s
+// 2. Caps and stats initialized with interval_s: 10
+// 3. Startup at 5s
+// 4. Resets triggered at 9s, 10s, 19s, 20s
+//
+// Expected:
+// At 10s: 1 reset for period starting at 0s
+// At 20s: 1 reset for period starting at 10s
+add_task(async function restart_4() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 10, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ gStartupDateMsStub.returns(5000);
+ await doTimedCallbacks({
+ // 9s: no resets
+ 9: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([]);
+ },
+ // 10s: 1 reset for period starting at 0s
+ 10: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "10",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ // 19s: no resets
+ 19: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([]);
+ },
+ // 20s: 1 reset for period starting at 10s
+ 20: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "20000",
+ intervalSeconds: "10",
+ maxCount: "1",
+ startDate: "10000",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+ gStartupDateMsStub.returns(0);
+});
+
+// Simulates reset events across a restart with the following:
+//
+// S S R
+// >---------|---------|
+// 0s 10 20
+//
+// 1. Startup at 0s
+// 2. Caps and stats initialized with interval_s: 10
+// 3. Startup at 5s
+// 4. Reset triggered at 20s
+//
+// Expected:
+// At 20s: 2 batched resets for periods starting at 0s
+add_task(async function restart_5() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 10, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ gStartupDateMsStub.returns(5000);
+ await doTimedCallbacks({
+ // 20s: 2 batches resets for periods starting at 0s
+ 20: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "20000",
+ intervalSeconds: "10",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "2",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+ gStartupDateMsStub.returns(0);
+});
+
+// Simulates reset events across a restart with the following:
+//
+// S S RR RR
+// >---------|---------|---------|
+// 0s 10 20 30
+//
+// 1. Startup at 0s
+// 2. Caps and stats initialized with interval_s: 10
+// 3. Startup at 15s
+// 4. Resets triggered at 19s, 20s, 29s, 30s
+//
+// Expected:
+// At 20s: 1 reset for period starting at 10s
+// At 30s: 1 reset for period starting at 20s
+add_task(async function restart_6() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 10, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ gStartupDateMsStub.returns(15000);
+ await doTimedCallbacks({
+ // 19s: no resets
+ 19: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([]);
+ },
+ // 20s: 1 reset for period starting at 10s
+ 20: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "20000",
+ intervalSeconds: "10",
+ maxCount: "1",
+ startDate: "10000",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ // 29s: no resets
+ 29: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([]);
+ },
+ // 30s: 1 reset for period starting at 20s
+ 30: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "30000",
+ intervalSeconds: "10",
+ maxCount: "1",
+ startDate: "20000",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "1",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+ gStartupDateMsStub.returns(0);
+});
+
+// Simulates reset events across a restart with the following:
+//
+// S S R
+// >---------|---------|---------|
+// 0s 10 20 30
+//
+// 1. Startup at 0s
+// 2. Caps and stats initialized with interval_s: 10
+// 3. Startup at 15s
+// 4. Reset triggered at 30s
+//
+// Expected:
+// At 30s: 2 batched resets for periods starting at 10s
+add_task(async function restart_7() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 10, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ gStartupDateMsStub.returns(15000);
+ await doTimedCallbacks({
+ // 30s: 2 batched resets for periods starting at 10s
+ 30: async () => {
+ QuickSuggest.impressionCaps._test_resetElapsedCounters();
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "30000",
+ intervalSeconds: "10",
+ maxCount: "1",
+ startDate: "10000",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "2",
+ },
+ },
+ ]);
+ },
+ });
+ },
+ });
+ gStartupDateMsStub.returns(0);
+});
+
+// Tests reset telemetry recorded on shutdown.
+add_task(async function shutdown() {
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 1, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ // Make `Date.now()` return 10s. Since the cap's `interval_s` is 1s and
+ // before this `Date.now()` returned 0s, 10 reset events should be
+ // recorded on shutdown.
+ gDateNowStub.returns(10000);
+
+ // Simulate shutdown.
+ Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
+ AsyncShutdown.profileChangeTeardown._trigger();
+
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: "10000",
+ intervalSeconds: "1",
+ maxCount: "1",
+ startDate: "0",
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ eventCount: "10",
+ },
+ },
+ ]);
+
+ gDateNowStub.returns(0);
+ Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+ },
+ });
+});
+
+// Tests the reset interval in realtime.
+add_task(async function resetInterval() {
+ // Remove the test stubs so we can test in realtime.
+ gDateNowStub.restore();
+ gStartupDateMsStub.restore();
+
+ await doTest({
+ config: {
+ impression_caps: {
+ sponsored: {
+ custom: [{ interval_s: 0.1, max_count: 1 }],
+ },
+ },
+ },
+ callback: async () => {
+ // Restart the reset interval now with a 1s period. Since the cap's
+ // `interval_s` is 0.1s, at least 10 reset events should be recorded the
+ // first time the reset interval fires. The exact number depends on timing
+ // and the machine running the test: how many 0.1s intervals elapse
+ // between when the config is set to when the reset interval fires. For
+ // that reason, we allow some leeway when checking `eventCount` below to
+ // avoid intermittent failures.
+ QuickSuggest.impressionCaps._test_setCountersResetInterval(1000);
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1100));
+
+ // Restore the reset interval to its default.
+ QuickSuggest.impressionCaps._test_setCountersResetInterval();
+
+ await checkTelemetryEvents([
+ {
+ object: "reset",
+ extra: {
+ eventDate: /^[0-9]+$/,
+ intervalSeconds: "0.1",
+ maxCount: "1",
+ startDate: /^[0-9]+$/,
+ impressionDate: "0",
+ count: "0",
+ type: "sponsored",
+ // See comment above on allowing leeway for `eventCount`.
+ eventCount: str => {
+ info(`Checking 'eventCount': ${str}`);
+ let count = parseInt(str);
+ return 10 <= count && count < 20;
+ },
+ },
+ },
+ ]);
+ },
+ });
+
+ // Recreate the test stubs.
+ gDateNowStub = gSandbox.stub(
+ Cu.getGlobalForObject(UrlbarProviderQuickSuggest).Date,
+ "now"
+ );
+ gStartupDateMsStub = gSandbox.stub(
+ QuickSuggest.impressionCaps,
+ "_getStartupDateMs"
+ );
+ gStartupDateMsStub.returns(0);
+});
+
+/**
+ * Main test helper. Sets up state, calls your callback, and resets state.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {object} options.config
+ * The quick suggest config to use during the test.
+ * @param {Function} options.callback
+ * The callback that will be run with the {@link config}
+ */
+async function doTest({ config, callback }) {
+ Services.telemetry.clearEvents();
+
+ // Make `Date.now()` return 0 to start with. It's necessary to do this before
+ // calling `withConfig()` because when a new config is set, the provider
+ // validates its impression stats, whose `startDateMs` values depend on
+ // `Date.now()`.
+ gDateNowStub.returns(0);
+
+ info(`Clearing stats and setting config`);
+ UrlbarPrefs.clear("quicksuggest.impressionCaps.stats");
+ QuickSuggest.impressionCaps._test_reloadStats();
+ await QuickSuggestTestUtils.withConfig({ config, callback });
+}
+
+/**
+ * Does a series of timed searches and checks their results and telemetry. This
+ * function relies on `doTimedCallbacks()`, so it may be helpful to look at it
+ * too.
+ *
+ * @param {string} searchString
+ * The query that should be timed
+ * @param {object} expectedBySecond
+ * An object that maps from seconds to objects that describe the searches to
+ * perform, their expected results, and the expected telemetry. For a given
+ * entry `S -> E` in this object, searches are performed S seconds after this
+ * function is called. `E` is an object that looks like this:
+ *
+ * { results, telemetry }
+ *
+ * {array} results
+ * An array of arrays. A search is performed for each sub-array in
+ * `results`, and the contents of the sub-array are the expected results
+ * for that search.
+ * {object} telemetry
+ * An object like this: { events }
+ * {array} events
+ * An array of expected telemetry events after all searches are done.
+ * Telemetry events are cleared after checking these. If not present,
+ * then it will be asserted that no events were recorded.
+ *
+ * Example:
+ *
+ * {
+ * 0: {
+ * results: [[R1], []],
+ * telemetry: {
+ * events: [
+ * someExpectedEvent,
+ * ],
+ * },
+ * }
+ * 1: {
+ * results: [[]],
+ * },
+ * }
+ *
+ * 0 seconds after `doTimedSearches()` is called, two searches are
+ * performed. The first one is expected to return a single result R1, and
+ * the second search is expected to return no results. After the searches
+ * are done, one telemetry event is expected to be recorded.
+ *
+ * 1 second after `doTimedSearches()` is called, one search is performed.
+ * It's expected to return no results, and no telemetry is expected to be
+ * recorded.
+ */
+async function doTimedSearches(searchString, expectedBySecond) {
+ await doTimedCallbacks(
+ Object.entries(expectedBySecond).reduce(
+ (memo, [second, { results, telemetry }]) => {
+ memo[second] = async () => {
+ for (let i = 0; i < results.length; i++) {
+ let expectedResults = results[i];
+ await checkSearch({
+ searchString,
+ expectedResults,
+ name: `${second}s search ${i + 1} of ${results.length}`,
+ });
+ }
+ let { events } = telemetry || {};
+ await checkTelemetryEvents(events || []);
+ };
+ return memo;
+ },
+ {}
+ )
+ );
+}
+
+/**
+ * Takes a series a callbacks and times at which they should be called, and
+ * calls them accordingly. This function is specifically designed for
+ * UrlbarProviderQuickSuggest and its impression capping implementation because
+ * it works by stubbing `Date.now()` within UrlbarProviderQuickSuggest. The
+ * callbacks are not actually called at the given times but instead `Date.now()`
+ * is stubbed so that UrlbarProviderQuickSuggest will think they are being
+ * called at the given times.
+ *
+ * A more general implementation of this helper function that isn't tailored to
+ * UrlbarProviderQuickSuggest is commented out below, and unfortunately it
+ * doesn't work properly on macOS.
+ *
+ * @param {object} callbacksBySecond
+ * An object that maps from seconds to callback functions. For a given entry
+ * `S -> F` in this object, the callback F is called S seconds after
+ * `doTimedCallbacks()` is called.
+ */
+async function doTimedCallbacks(callbacksBySecond) {
+ let entries = Object.entries(callbacksBySecond).sort(([t1], [t2]) => t1 - t2);
+ for (let [timeoutSeconds, callback] of entries) {
+ gDateNowStub.returns(1000 * timeoutSeconds);
+ await callback();
+ }
+}
+
+/*
+// This is the original implementation of `doTimedCallbacks()`, left here for
+// reference or in case the macOS problem described below is fixed. Instead of
+// stubbing `Date.now()` within UrlbarProviderQuickSuggest, it starts parallel
+// timers so that the callbacks are actually called at appropriate times. This
+// version of `doTimedCallbacks()` is therefore more generally useful, but it
+// has the drawback that your test has to run in real time. e.g., if one of your
+// callbacks needs to run 10s from now, the test must actually wait 10s.
+//
+// Unfortunately macOS seems to have some kind of limit of ~33 total 1-second
+// timers during any xpcshell test -- not 33 simultaneous timers but 33 total
+// timers. After that, timers fire randomly and with huge timeout periods that
+// are often exactly 10s greater than the specified period, as if some 10s
+// timeout internal to macOS is being hit. This problem does not seem to happen
+// when running the full browser, only during xpcshell tests. In fact the
+// problem can be reproduced in an xpcshell test that simply creates an interval
+// timer whose period is 1s (e.g., using `setInterval()` from Timer.sys.mjs).
+// After ~33 ticks, the timer's period jumps to ~10s.
+async function doTimedCallbacks(callbacksBySecond) {
+ await Promise.all(
+ Object.entries(callbacksBySecond).map(
+ ([timeoutSeconds, callback]) => new Promise(
+ resolve => setTimeout(
+ () => callback().then(resolve),
+ 1000 * parseInt(timeoutSeconds)
+ )
+ )
+ )
+ );
+}
+*/
+
+/**
+ * Does a search, triggers an engagement, and checks the results.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {string} options.name
+ * This value is the name of the search and will be logged in messages to make
+ * debugging easier.
+ * @param {string} options.searchString
+ * The query that should be searched.
+ * @param {Array} options.expectedResults
+ * The results that are expected from the search.
+ */
+async function checkSearch({ name, searchString, expectedResults }) {
+ info(`Preparing search "${name}" with search string "${searchString}"`);
+ let context = createContext(searchString, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ info(`Doing search: ${name}`);
+ await check_results({
+ context,
+ matches: expectedResults,
+ });
+ info(`Finished search: ${name}`);
+
+ // Impression stats are updated only on engagement, so force one now.
+ // `selIndex` doesn't really matter but since we're not trying to simulate a
+ // click on the suggestion, pass in -1 to ensure we don't record a click.
+ if (UrlbarProviderQuickSuggest._resultFromLastQuery) {
+ UrlbarProviderQuickSuggest._resultFromLastQuery.isVisible = true;
+ }
+ const controller = UrlbarTestUtils.newMockController({
+ input: {
+ isPrivate: true,
+ onFirstResult() {
+ return false;
+ },
+ getSearchSource() {
+ return "dummy-search-source";
+ },
+ window: {
+ location: {
+ href: AppConstants.BROWSER_CHROME_URL,
+ },
+ },
+ },
+ });
+ controller.setView({
+ get visibleResults() {
+ return context.results;
+ },
+ controller: {
+ removeResult() {},
+ },
+ });
+ UrlbarProviderQuickSuggest.onEngagement(
+ "engagement",
+ context,
+ {
+ selIndex: -1,
+ },
+ controller
+ );
+}
+
+async function checkTelemetryEvents(expectedEvents) {
+ QuickSuggestTestUtils.assertEvents(
+ expectedEvents.map(event => ({
+ ...event,
+ category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
+ method: "impression_cap",
+ })),
+ // Filter in only impression_cap events.
+ { method: "impression_cap" }
+ );
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js
new file mode 100644
index 0000000000..e9bccba649
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_mdn.js
@@ -0,0 +1,190 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests MDN quick suggest results.
+
+"use strict";
+
+const REMOTE_SETTINGS_DATA = [
+ {
+ type: "mdn-suggestions",
+ attachment: [
+ {
+ url: "https://example.com/array-filter",
+ title: "Array.prototype.filter()",
+ description:
+ "The filter() method creates a shallow copy of a portion of a given array, filtered down to just the elements from the given array that pass the test implemented by the provided function.",
+ keywords: ["array filter"],
+ score: 0.24,
+ },
+ {
+ url: "https://example.com/input",
+ title: "<input>: The Input (Form Input) element",
+ description:
+ "The <input> HTML element is used to create interactive controls for web-based forms in order to accept data from the user; a wide variety of types of input data and control widgets are available, depending on the device and user agent. The <input> element is one of the most powerful and complex in all of HTML due to the sheer number of combinations of input types and attributes.",
+ keywords: ["input"],
+ score: 0.24,
+ },
+ {
+ url: "https://example.com/grid",
+ title: "CSS Grid Layout",
+ description:
+ "CSS Grid Layout excels at dividing a page into major regions or defining the relationship in terms of size, position, and layer, between parts of a control built from HTML primitives.",
+ keywords: ["grid"],
+ score: 0.24,
+ },
+ ],
+ },
+];
+
+add_setup(async function init() {
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_DATA,
+ prefs: [
+ ["suggest.quicksuggest.nonsponsored", true],
+ ["suggest.quicksuggest.sponsored", false],
+ ],
+ });
+});
+
+add_tasks_with_rust(async function basic() {
+ for (const suggestion of REMOTE_SETTINGS_DATA[0].attachment) {
+ const fullKeyword = suggestion.keywords[0];
+ const firstWord = fullKeyword.split(" ")[0];
+ for (let i = 1; i < fullKeyword.length; i++) {
+ const keyword = fullKeyword.substring(0, i);
+ const shouldMatch = i >= firstWord.length;
+ const matches = shouldMatch ? [makeMdnResult(suggestion)] : [];
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches,
+ });
+ }
+
+ await check_results({
+ context: createContext(fullKeyword + " ", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches:
+ UrlbarPrefs.get("quickSuggestRustEnabled") && !fullKeyword.includes(" ")
+ ? [makeMdnResult(suggestion)]
+ : [],
+ });
+ }
+});
+
+// Check wheather the MDN suggestions will be hidden by the pref.
+add_tasks_with_rust(async function disableByLocalPref() {
+ const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0];
+ const keyword = suggestion.keywords[0];
+
+ const prefs = [
+ "suggest.mdn",
+ "quicksuggest.enabled",
+ "suggest.quicksuggest.nonsponsored",
+ ];
+
+ for (const pref of prefs) {
+ // First make sure the suggestion is added.
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeMdnResult(suggestion)],
+ });
+
+ // Now disable them.
+ UrlbarPrefs.set(pref, false);
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Revert.
+ UrlbarPrefs.set(pref, true);
+ await QuickSuggestTestUtils.forceSync();
+ }
+});
+
+// Check wheather the MDN suggestions will be shown by the setup of Nimbus
+// variable.
+add_tasks_with_rust(async function nimbus() {
+ const defaultPrefs = Services.prefs.getDefaultBranch("browser.urlbar.");
+
+ const suggestion = REMOTE_SETTINGS_DATA[0].attachment[0];
+ const keyword = suggestion.keywords[0];
+
+ // Disable the fature gate.
+ defaultPrefs.setBoolPref("mdn.featureGate", false);
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Enable by Nimbus.
+ const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature(
+ { mdnFeatureGate: true },
+ "urlbar",
+ "config"
+ );
+ await QuickSuggestTestUtils.forceSync();
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeMdnResult(suggestion)],
+ });
+ await cleanUpNimbusEnable();
+
+ // Enable locally.
+ defaultPrefs.setBoolPref("mdn.featureGate", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Disable by Nimbus.
+ const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature(
+ { mdnFeatureGate: false },
+ "urlbar",
+ "config"
+ );
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+ await cleanUpNimbusDisable();
+
+ // Revert.
+ defaultPrefs.setBoolPref("mdn.featureGate", true);
+ await QuickSuggestTestUtils.forceSync();
+});
+
+add_tasks_with_rust(async function mixedCaseQuery() {
+ const suggestion = REMOTE_SETTINGS_DATA[0].attachment[1];
+ const keyword = "InPuT";
+
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeMdnResult(suggestion)],
+ });
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js
new file mode 100644
index 0000000000..64f4991236
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merino.js
@@ -0,0 +1,574 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests Merino integration with UrlbarProviderQuickSuggest.
+
+"use strict";
+
+// relative to `browser.urlbar`
+const PREF_DATA_COLLECTION_ENABLED = "quicksuggest.dataCollection.enabled";
+
+const SEARCH_STRING = "frab";
+
+const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest;
+
+const REMOTE_SETTINGS_RESULTS = [
+ QuickSuggestTestUtils.ampRemoteSettings({
+ keywords: [SEARCH_STRING],
+ }),
+];
+
+const EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT = makeAmpResult({
+ keyword: SEARCH_STRING,
+});
+
+const EXPECTED_MERINO_URLBAR_RESULT = makeAmpResult({
+ source: "merino",
+ provider: "adm",
+ requestId: "request_id",
+});
+
+// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino
+// fetch, so it's easiest to create `gClient` lazily too.
+ChromeUtils.defineLazyGetter(
+ this,
+ "gClient",
+ () => UrlbarProviderQuickSuggest._test_merino
+);
+
+add_setup(async () => {
+ await MerinoTestUtils.server.start();
+
+ // Set up the remote settings client with the test data.
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ],
+ prefs: [
+ ["suggest.quicksuggest.nonsponsored", true],
+ ["suggest.quicksuggest.sponsored", true],
+ ],
+ });
+
+ Assert.equal(
+ typeof DEFAULT_SUGGESTION_SCORE,
+ "number",
+ "Sanity check: DEFAULT_SUGGESTION_SCORE is defined"
+ );
+});
+
+// Tests with the Merino endpoint URL set to an empty string, which disables
+// fetching from Merino.
+add_task(async function merinoDisabled() {
+ let mockEndpointUrl = UrlbarPrefs.get("merino.endpointURL");
+ UrlbarPrefs.set("merino.endpointURL", "");
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ // Clear the remote settings suggestions so that if Merino is actually queried
+ // -- which would be a bug -- we don't accidentally mask the Merino suggestion
+ // by also matching an RS suggestion with the same or higher score.
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([]);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: null,
+ latencyRecorded: false,
+ client: UrlbarProviderQuickSuggest._test_merino,
+ });
+
+ UrlbarPrefs.set("merino.endpointURL", mockEndpointUrl);
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ]);
+});
+
+// Tests with Merino enabled but with data collection disabled. Results should
+// not be fetched from Merino in that case.
+add_task(async function dataCollectionDisabled() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, false);
+
+ // Clear the remote settings suggestions so that if Merino is actually queried
+ // -- which would be a bug -- we don't accidentally mask the Merino suggestion
+ // by also matching an RS suggestion with the same or higher score.
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([]);
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "data",
+ attachment: REMOTE_SETTINGS_RESULTS,
+ },
+ ]);
+});
+
+// When the Merino suggestion has a higher score than the remote settings
+// suggestion, the Merino suggestion should be used.
+add_task(async function higherScore() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ MerinoTestUtils.server.response.body.suggestions[0].score =
+ 2 * DEFAULT_SUGGESTION_SCORE;
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [EXPECTED_MERINO_URLBAR_RESULT],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// When the Merino suggestion has a lower score than the remote settings
+// suggestion, the remote settings suggestion should be used.
+add_task(async function lowerScore() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ MerinoTestUtils.server.response.body.suggestions[0].score =
+ DEFAULT_SUGGESTION_SCORE / 2;
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// When the Merino and remote settings suggestions have the same score, the
+// remote settings suggestion should be used.
+add_task(async function sameScore() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ MerinoTestUtils.server.response.body.suggestions[0].score =
+ DEFAULT_SUGGESTION_SCORE;
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// When the Merino suggestion does not include a score, the remote settings
+// suggestion should be used.
+add_task(async function noMerinoScore() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ Assert.equal(
+ typeof MerinoTestUtils.server.response.body.suggestions[0].score,
+ "number",
+ "Sanity check: First suggestion has a score"
+ );
+ delete MerinoTestUtils.server.response.body.suggestions[0].score;
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// When remote settings doesn't return a suggestion but Merino does, the Merino
+// suggestion should be used.
+add_task(async function noSuggestion_remoteSettings() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ let context = createContext("this doesn't match remote settings", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [EXPECTED_MERINO_URLBAR_RESULT],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// When Merino doesn't return a suggestion but remote settings does, the remote
+// settings suggestion should be used.
+add_task(async function noSuggestion_merino() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ MerinoTestUtils.server.response.body.suggestions = [];
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "no_suggestion",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// When Merino returns multiple suggestions, the one with the largest score
+// should be used.
+add_task(async function multipleMerinoSuggestions() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ MerinoTestUtils.server.response.body.suggestions = [
+ {
+ provider: "adm",
+ full_keyword: "multipleMerinoSuggestions 0 full_keyword",
+ title: "multipleMerinoSuggestions 0 title",
+ url: "multipleMerinoSuggestions 0 url",
+ icon: "multipleMerinoSuggestions 0 icon",
+ impression_url: "multipleMerinoSuggestions 0 impression_url",
+ click_url: "multipleMerinoSuggestions 0 click_url",
+ block_id: 0,
+ advertiser: "multipleMerinoSuggestions 0 advertiser",
+ iab_category: "22 - Shopping",
+ is_sponsored: true,
+ score: 0.1,
+ },
+ {
+ provider: "adm",
+ full_keyword: "multipleMerinoSuggestions 1 full_keyword",
+ title: "multipleMerinoSuggestions 1 title",
+ url: "multipleMerinoSuggestions 1 url",
+ icon: "multipleMerinoSuggestions 1 icon",
+ impression_url: "multipleMerinoSuggestions 1 impression_url",
+ click_url: "multipleMerinoSuggestions 1 click_url",
+ block_id: 1,
+ advertiser: "multipleMerinoSuggestions 1 advertiser",
+ iab_category: "22 - Shopping",
+ is_sponsored: true,
+ score: 1,
+ },
+ {
+ provider: "adm",
+ full_keyword: "multipleMerinoSuggestions 2 full_keyword",
+ title: "multipleMerinoSuggestions 2 title",
+ url: "multipleMerinoSuggestions 2 url",
+ icon: "multipleMerinoSuggestions 2 icon",
+ impression_url: "multipleMerinoSuggestions 2 impression_url",
+ click_url: "multipleMerinoSuggestions 2 click_url",
+ block_id: 2,
+ advertiser: "multipleMerinoSuggestions 2 advertiser",
+ iab_category: "22 - Shopping",
+ is_sponsored: true,
+ score: 0.2,
+ },
+ ];
+
+ let context = createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [
+ makeAmpResult({
+ keyword: "multipleMerinoSuggestions 1 full_keyword",
+ title: "multipleMerinoSuggestions 1 title",
+ url: "multipleMerinoSuggestions 1 url",
+ originalUrl: "multipleMerinoSuggestions 1 url",
+ icon: "multipleMerinoSuggestions 1 icon",
+ impressionUrl: "multipleMerinoSuggestions 1 impression_url",
+ clickUrl: "multipleMerinoSuggestions 1 click_url",
+ blockId: 1,
+ advertiser: "multipleMerinoSuggestions 1 advertiser",
+ requestId: "request_id",
+ source: "merino",
+ provider: "adm",
+ }),
+ ],
+ });
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// Timestamp templates in URLs should be replaced with real timestamps.
+add_task(async function timestamps() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ // Set up the Merino response with template URLs.
+ let suggestion = MerinoTestUtils.server.response.body.suggestions[0];
+ let { TIMESTAMP_TEMPLATE } = QuickSuggest;
+
+ suggestion.url = `http://example.com/time-${TIMESTAMP_TEMPLATE}`;
+ suggestion.click_url = `http://example.com/time-${TIMESTAMP_TEMPLATE}-foo`;
+
+ // Do a search.
+ let context = createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ let controller = UrlbarTestUtils.newMockController({
+ input: {
+ isPrivate: context.isPrivate,
+ onFirstResult() {
+ return false;
+ },
+ getSearchSource() {
+ return "dummy-search-source";
+ },
+ window: {
+ location: {
+ href: AppConstants.BROWSER_CHROME_URL,
+ },
+ },
+ },
+ });
+ await controller.startQuery(context);
+
+ // Should be one quick suggest result.
+ Assert.equal(context.results.length, 1, "One result returned");
+ let result = context.results[0];
+
+ QuickSuggestTestUtils.assertTimestampsReplaced(result, {
+ url: suggestion.click_url,
+ sponsoredClickUrl: suggestion.click_url,
+ });
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// When both suggestion types are disabled but data collection is enabled, we
+// should still send requests to Merino, and the requests should include an
+// empty `providers` to tell Merino not to fetch any suggestions.
+add_task(async function suggestedDisabled_dataCollectionEnabled() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+
+ let histograms = MerinoTestUtils.getAndClearHistograms();
+
+ let context = createContext("test", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ // Check that the request is received and includes an empty `providers`.
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: "test",
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ [MerinoTestUtils.SEARCH_PARAMS.PROVIDERS]: "",
+ },
+ },
+ ]);
+
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: gClient,
+ });
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+ gClient.resetSession();
+});
+
+// Test whether the blocking for Merino results works.
+add_task(async function block() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ // Make sure the Merino suggestions have different URLs from the remote
+ // settings suggestion.
+ let { suggestions } = MerinoTestUtils.server.response.body;
+ for (let i = 0; i < suggestions.length; i++) {
+ let suggestion = suggestions[i];
+ suggestion.url = "https://example.com/merino-url-" + i;
+ await QuickSuggest.blockedSuggestions.add(suggestion.url);
+ }
+
+ const context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+
+ await check_results({
+ context,
+ matches: [EXPECTED_REMOTE_SETTINGS_URLBAR_RESULT],
+ });
+
+ await QuickSuggest.blockedSuggestions.clear();
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
+
+// Tests a Merino suggestion that is a top pick/best match.
+add_task(async function bestMatch() {
+ UrlbarPrefs.set(PREF_DATA_COLLECTION_ENABLED, true);
+
+ // Set up a suggestion with `is_top_pick` and an unknown provider so that
+ // UrlbarProviderQuickSuggest will make a default result for it.
+ MerinoTestUtils.server.response.body.suggestions = [
+ {
+ is_top_pick: true,
+ provider: "some_top_pick_provider",
+ full_keyword: "full_keyword",
+ title: "title",
+ url: "url",
+ icon: null,
+ score: 1,
+ },
+ ];
+
+ let context = createContext(SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [
+ {
+ isBestMatch: true,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ telemetryType: "some_top_pick_provider",
+ title: "title",
+ url: "url",
+ icon: null,
+ qsSuggestion: "full_keyword",
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ displayUrl: "url",
+ source: "merino",
+ provider: "some_top_pick_provider",
+ },
+ },
+ ],
+ });
+
+ // This isn't necessary since `check_results()` checks `isBestMatch`, but
+ // check it here explicitly for good measure.
+ Assert.ok(context.results[0].isBestMatch, "Result is a best match");
+
+ MerinoTestUtils.server.reset();
+ gClient.resetSession();
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js
new file mode 100644
index 0000000000..61b1b9186f
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_merinoSessions.js
@@ -0,0 +1,173 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests Merino session integration with UrlbarProviderQuickSuggest.
+
+"use strict";
+
+// `UrlbarProviderQuickSuggest.#merino` is lazily created on the first Merino
+// fetch, so it's easiest to create `gClient` lazily too.
+ChromeUtils.defineLazyGetter(
+ this,
+ "gClient",
+ () => UrlbarProviderQuickSuggest._test_merino
+);
+
+add_setup(async () => {
+ await MerinoTestUtils.server.start();
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ prefs: [
+ ["suggest.quicksuggest.sponsored", true],
+ ["quicksuggest.dataCollection.enabled", true],
+ ],
+ });
+});
+
+// In a single engagement, all requests should use the same session ID and the
+// sequence number should be incremented.
+add_task(async function singleEngagement() {
+ let controller = UrlbarTestUtils.newMockController();
+
+ for (let i = 0; i < 3; i++) {
+ let searchString = "search" + i;
+ await controller.startQuery(
+ createContext(searchString, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ })
+ );
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: i,
+ },
+ },
+ ]);
+ }
+
+ // End the engagement to reset the session for the next test.
+ endEngagement({ controller });
+});
+
+// New engagements should not use the same session ID as previous engagements
+// and the sequence number should be reset. This task completes each engagement
+// successfully.
+add_task(async function manyEngagements_engagement() {
+ await doManyEngagementsTest("engagement");
+});
+
+// New engagements should not use the same session ID as previous engagements
+// and the sequence number should be reset. This task abandons each engagement.
+add_task(async function manyEngagements_abandonment() {
+ await doManyEngagementsTest("abandonment");
+});
+
+async function doManyEngagementsTest(state) {
+ let controller = UrlbarTestUtils.newMockController();
+
+ for (let i = 0; i < 3; i++) {
+ let searchString = "search" + i;
+ let context = createContext(searchString, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+ await controller.startQuery(context);
+
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
+ },
+ },
+ ]);
+
+ endEngagement({ context, state, controller });
+ }
+}
+
+// When a search is canceled after the request is sent and before the Merino
+// response is received, the sequence number should still be incremented.
+add_task(async function canceledQueries() {
+ let controller = UrlbarTestUtils.newMockController();
+
+ for (let i = 0; i < 3; i++) {
+ // Send the first response after a delay to make sure the client will not
+ // receive it before we start the second fetch.
+ MerinoTestUtils.server.response.delay = UrlbarPrefs.get("merino.timeoutMs");
+
+ // Start the first search.
+ let requestPromise = MerinoTestUtils.server.waitForNextRequest();
+ let searchString1 = "search" + i;
+ controller.startQuery(
+ createContext(searchString1, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ })
+ );
+
+ // Wait until the first request is received before starting the second
+ // search. If we started the second search immediately, the first would be
+ // canceled before the provider is even called due to the urlbar's 50ms
+ // delay (see `browser.urlbar.delay`) so the sequence number would not be
+ // incremented for it. Here we want to test the case where the first search
+ // is canceled after the request is sent and the number is incremented.
+ await requestPromise;
+ delete MerinoTestUtils.server.response.delay;
+
+ // Now do a second search that cancels the first.
+ let searchString2 = searchString1 + "again";
+ await controller.startQuery(
+ createContext(searchString2, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ })
+ );
+
+ // The sequence number should have been incremented for each search.
+ MerinoTestUtils.server.checkAndClearRequests([
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString1,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i,
+ },
+ },
+ {
+ params: {
+ [MerinoTestUtils.SEARCH_PARAMS.QUERY]: searchString2,
+ [MerinoTestUtils.SEARCH_PARAMS.SEQUENCE_NUMBER]: 2 * i + 1,
+ },
+ },
+ ]);
+ }
+
+ // End the engagement to reset the session for the next test.
+ endEngagement({ controller });
+});
+
+function endEngagement({ controller, context = null, state = "engagement" }) {
+ UrlbarProviderQuickSuggest.onEngagement(
+ state,
+ context ||
+ createContext("endEngagement", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ { selIndex: -1 },
+ controller
+ );
+
+ Assert.strictEqual(
+ gClient.sessionID,
+ null,
+ "sessionID is null after engagement"
+ );
+ Assert.strictEqual(
+ gClient._test_sessionTimer,
+ null,
+ "sessionTimer is null after engagement"
+ );
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js
new file mode 100644
index 0000000000..851757b11b
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v1.js
@@ -0,0 +1,490 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests quick suggest prefs migration from unversioned prefs to version 1.
+
+"use strict";
+
+// Expected version 1 default-branch prefs
+const DEFAULT_PREFS = {
+ history: {
+ "quicksuggest.enabled": false,
+ },
+ offline: {
+ "quicksuggest.enabled": true,
+ "quicksuggest.dataCollection.enabled": false,
+ "quicksuggest.shouldShowOnboardingDialog": false,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ online: {
+ "quicksuggest.enabled": true,
+ "quicksuggest.dataCollection.enabled": false,
+ "quicksuggest.shouldShowOnboardingDialog": true,
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+};
+
+// Migration will use these values to migrate only up to version 1 instead of
+// the current version.
+const TEST_OVERRIDES = {
+ migrationVersion: 1,
+ defaultPrefs: DEFAULT_PREFS,
+};
+
+add_setup(async () => {
+ await UrlbarTestUtils.initNimbusFeature();
+});
+
+// The following tasks test OFFLINE TO OFFLINE
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * User did not override any defaults
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain on
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ },
+ });
+});
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * Main suggestions pref: user left on
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: remain off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user left on (but ignored since main was off)
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// The following tasks test OFFLINE TO ONLINE
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * User did not override any defaults
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * Main suggestions pref: user left on
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user left on (but ignored since main was off)
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Offline (suggestions on by default)
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// The following tasks test ONLINE TO OFFLINE
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * User did not override any defaults
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: on (since main pref had default value)
+// * Sponsored suggestions: on (since main & sponsored prefs had default values)
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ },
+ });
+});
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * Main suggestions pref: user left off
+// * Sponsored suggestions: user turned on (but ignored since main was off)
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: on (see below)
+// * Data collection: off
+//
+// It's unfortunate that sponsored suggestions are ultimately on since before
+// the migration no suggestions were shown to the user. There's nothing we can
+// do about it, aside from forcing off suggestions in more cases than we want.
+// The reason is that at the time of migration we can't tell that the previous
+// scenario was online -- or more precisely that it wasn't history. If we knew
+// it wasn't history, then we'd know to turn sponsored off; if we knew it *was*
+// history, then we'd know to turn sponsored -- and non-sponsored -- on, since
+// the scenario at the time of migration is offline, where suggestions should be
+// enabled by default.
+//
+// This is the reason we now record `quicksuggest.scenario` on the user branch
+// and not the default branch as we previously did.
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * Main suggestions pref: user turned on
+// * Sponsored suggestions: user left off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain off
+// * Data collection: off (since scenario is offline)
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": true,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * Main suggestions pref: user turned on
+// * Sponsored suggestions: user turned on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain on
+// * Data collection: off (since scenario is offline)
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+});
+
+// The following tasks test ONLINE TO ONLINE
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * User did not override any defaults
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * Main suggestions pref: user left off
+// * Sponsored suggestions: user turned on (but ignored since main was off)
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * Main suggestions pref: user turned on
+// * Sponsored suggestions: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain off
+// * Data collection: ON (since user effectively opted in by turning on
+// suggestions)
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Online (suggestions off by default)
+// * Main suggestions pref: user turned on
+// * Sponsored suggestions: user turned on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: remain on
+// * Sponsored suggestions: remain on
+// * Data collection: ON (since user effectively opted in by turning on
+// suggestions)
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js
new file mode 100644
index 0000000000..991e8c66f9
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_migrate_v2.js
@@ -0,0 +1,1355 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests quick suggest prefs migration to version 2.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+// Expected version 2 default-branch prefs
+const DEFAULT_PREFS = {
+ history: {
+ "quicksuggest.enabled": false,
+ },
+ offline: {
+ "quicksuggest.enabled": true,
+ "quicksuggest.dataCollection.enabled": false,
+ "quicksuggest.shouldShowOnboardingDialog": false,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ online: {
+ "quicksuggest.enabled": true,
+ "quicksuggest.dataCollection.enabled": false,
+ "quicksuggest.shouldShowOnboardingDialog": true,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+};
+
+// Migration will use these values to migrate only up to version 1 instead of
+// the current version.
+// Currently undefined because version 2 is the current migration version and we
+// want migration to use its actual values, not overrides. When version 3 is
+// added, set this to an object like the one in test_quicksuggest_migrate_v1.js.
+const TEST_OVERRIDES = undefined;
+
+add_setup(async () => {
+ await UrlbarTestUtils.initNimbusFeature();
+});
+
+// The following tasks test OFFLINE UNVERSIONED to OFFLINE
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user left on
+// * Sponsored suggestions: user left on
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user left on (but ignored since main was off)
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user left on
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// The following tasks test OFFLINE UNVERSIONED to ONLINE
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user left on
+// * Sponsored suggestions: user left on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user left on (but ignored since main was off)
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user left on
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Offline
+// * Main suggestions pref: user turned off
+// * Sponsored suggestions: user turned off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// The following tasks test ONLINE UNVERSIONED to ONLINE when the user was NOT
+// SHOWN THE MODAL (e.g., because they didn't restart)
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: no
+// * User enrolled in online where suggestions were disabled by default, did not
+// turn on either type of suggestion, was not shown the modal (e.g., because
+// they didn't restart), and upgraded to v2
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await withOnlineExperiment(async () => {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ },
+ });
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: no
+// * User enrolled in online where suggestions were disabled by default, turned
+// on main suggestions pref and left off sponsored suggestions, was not shown
+// the modal (e.g., because they didn't restart), and upgraded to v2
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: on (since they opted in by checking the main checkbox
+// while in online)
+add_task(async function () {
+ await withOnlineExperiment(async () => {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ },
+ },
+ });
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: no
+// * User enrolled in online where suggestions were disabled by default, left
+// off main suggestions pref and turned on sponsored suggestions, was not
+// shown the modal (e.g., because they didn't restart), and upgraded to v2
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await withOnlineExperiment(async () => {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: no
+// * User enrolled in online where suggestions were disabled by default, turned
+// on main suggestions pref and sponsored suggestions, was not shown the
+// modal (e.g., because they didn't restart), and upgraded to v2
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: on (since they opted in by checking the main checkbox
+// while in online)
+add_task(async function () {
+ await withOnlineExperiment(async () => {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "suggest.quicksuggest": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+ });
+});
+
+// The following tasks test ONLINE UNVERSIONED to ONLINE when the user WAS SHOWN
+// THE MODAL
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: yes
+// * The following end up with same prefs and are covered by this task:
+// 1. User did not opt in and left off both the main suggestions pref and
+// sponsored suggestions
+// 2. User opted in but then later turned off both the main suggestions pref
+// and sponsored suggestions
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.showedOnboardingDialog": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: yes
+// * The following end up with same prefs and are covered by this task:
+// 1. User did not opt in but then later turned on the main suggestions pref
+// 2. User opted in but then later turned off sponsored suggestions
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.showedOnboardingDialog": true,
+ "suggest.quicksuggest": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: yes
+// * The following end up with same prefs and are covered by this task:
+// 1. User did not opt in but then later turned on sponsored suggestions
+// 2. User opted in but then later turned off the main suggestions pref
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.showedOnboardingDialog": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Unversioned prefs
+// * Online
+// * Modal shown: yes
+// * The following end up with same prefs and are covered by this task:
+// 1. User did not opt in but then later turned on both the main suggestions
+// pref and sponsored suggestions
+// 2. User opted in and left on both the main suggestions pref and sponsored
+// suggestions
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.showedOnboardingDialog": true,
+ "suggest.quicksuggest": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// The following tasks test OFFLINE VERSION 1 to OFFLINE
+
+// Migrating from:
+// * Version 1 prefs
+// * Offline
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "offline",
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Offline
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user turned on
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "offline",
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Offline
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "offline",
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Offline
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Offline
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "offline",
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "offline",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.offline,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// The following tasks test OFFLINE VERSION 1 to ONLINE
+
+// Migrating from:
+// * Version 1 prefs
+// * Offline
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "offline",
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Offline
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "offline",
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Offline
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user turned off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "offline",
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// The following tasks test ONLINE VERSION 1 to ONLINE when the user was NOT
+// SHOWN THE MODAL (e.g., because they didn't restart)
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user left off
+// * Sponsored suggestions: user left off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user left off
+// * Sponsored suggestions: user left off
+// * Data collection: user turned on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user left off
+// * Sponsored suggestions: user turned on
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: on
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user left off
+// * Sponsored suggestions: user turned on
+// * Data collection: user turned on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user turned on
+// * Sponsored suggestions: user left off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "suggest.quicksuggest.nonsponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user turned on
+// * Sponsored suggestions: user left off
+// * Data collection: user turned on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "suggest.quicksuggest.nonsponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user turned on
+// * Sponsored suggestions: user turned on
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: no
+// * Non-sponsored suggestions: user turned on
+// * Sponsored suggestions: user turned on
+// * Data collection: user turned on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN
+// THE MODAL WHILE PREFS WERE UNVERSIONED
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: yes, while prefs were unversioned
+// * User opted in: no
+// * Non-sponsored suggestions: user left off
+// * Sponsored suggestions: user left off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.showedOnboardingDialog": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: yes, while prefs were unversioned
+// * User opted in: no
+// * Non-sponsored suggestions: user turned on
+// * Sponsored suggestions: user left off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.showedOnboardingDialog": true,
+ "suggest.quicksuggest.nonsponsored": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: yes, while prefs were unversioned
+// * User opted in: yes
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user left on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.showedOnboardingDialog": true,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: yes, while prefs were unversioned
+// * User opted in: yes
+// * Non-sponsored suggestions: user turned off
+// * Sponsored suggestions: user left on
+// * Data collection: user left on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.showedOnboardingDialog": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: yes, while prefs were unversioned
+// * User opted in: yes
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user turned off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.showedOnboardingDialog": true,
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ });
+});
+
+// The following tasks test ONLINE VERSION 1 to ONLINE when the user WAS SHOWN
+// THE MODAL WHILE PREFS WERE VERSION 1
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: yes, while prefs were version 1
+// * User opted in: no
+// * Non-sponsored suggestions: user left off
+// * Sponsored suggestions: user left off
+// * Data collection: user left off
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: off
+// * Sponsored suggestions: off
+// * Data collection: off
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.showedOnboardingDialog": true,
+ "quicksuggest.onboardingDialogChoice": "not_now_link",
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": false,
+ "suggest.quicksuggest.sponsored": false,
+ "quicksuggest.dataCollection.enabled": false,
+ },
+ },
+ });
+});
+
+// Migrating from:
+// * Version 1 prefs
+// * Online
+// * Modal shown: yes, while prefs were version 1
+// * User opted in: yes
+// * Non-sponsored suggestions: user left on
+// * Sponsored suggestions: user left on
+// * Data collection: user left on
+//
+// Scenario when migration occurs:
+// * Online
+//
+// Expected:
+// * Non-sponsored suggestions: on
+// * Sponsored suggestions: on
+// * Data collection: on
+add_task(async function () {
+ await doMigrateTest({
+ testOverrides: TEST_OVERRIDES,
+ initialUserBranch: {
+ "quicksuggest.migrationVersion": 1,
+ "quicksuggest.scenario": "online",
+ "quicksuggest.showedOnboardingDialog": true,
+ "quicksuggest.onboardingDialogChoice": "accept",
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ scenario: "online",
+ expectedPrefs: {
+ defaultBranch: DEFAULT_PREFS.online,
+ userBranch: {
+ "suggest.quicksuggest.nonsponsored": true,
+ "suggest.quicksuggest.sponsored": true,
+ "quicksuggest.dataCollection.enabled": true,
+ },
+ },
+ });
+});
+
+async function withOnlineExperiment(callback) {
+ let { enrollmentPromise, doExperimentCleanup } =
+ ExperimentFakes.enrollmentHelper(
+ ExperimentFakes.recipe("firefox-suggest-offline-vs-online", {
+ active: true,
+ branches: [
+ {
+ slug: "treatment",
+ features: [
+ {
+ featureId: NimbusFeatures.urlbar.featureId,
+ value: {
+ enabled: true,
+ },
+ },
+ ],
+ },
+ ],
+ })
+ );
+ await enrollmentPromise;
+ await callback();
+ await doExperimentCleanup();
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js
new file mode 100644
index 0000000000..8ac7b85ba2
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_nonUniqueKeywords.js
@@ -0,0 +1,285 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests non-unique keywords, i.e., keywords used by multiple suggestions.
+
+"use strict";
+
+const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest;
+
+// For each of these objects, the test creates a quick suggest result (the kind
+// stored in the remote settings data, not a urlbar result), the corresponding
+// expected quick suggest suggestion, and the corresponding expected urlbar
+// result. The test assumes results and suggestions are returned in the order
+// listed here.
+let SUGGESTIONS_DATA = [
+ {
+ keywords: ["aaa"],
+ isSponsored: true,
+ score: DEFAULT_SUGGESTION_SCORE,
+ },
+ {
+ keywords: ["aaa", "bbb"],
+ isSponsored: false,
+ score: 2 * DEFAULT_SUGGESTION_SCORE,
+ },
+ {
+ keywords: ["bbb"],
+ isSponsored: true,
+ score: 4 * DEFAULT_SUGGESTION_SCORE,
+ },
+ {
+ keywords: ["bbb"],
+ isSponsored: false,
+ score: 3 * DEFAULT_SUGGESTION_SCORE,
+ },
+ {
+ keywords: ["ccc"],
+ isSponsored: true,
+ score: DEFAULT_SUGGESTION_SCORE,
+ },
+];
+
+// Test cases. In this object, keywords map to subtest cases. For each keyword,
+// the test calls `query(keyword)` and checks that the indexes (relative to
+// `SUGGESTIONS_DATA`) of the returned quick suggest results are the ones in
+// `expectedIndexes`. Then the test does a series of urlbar searches using the
+// keyword as the search string, one search per object in `searches`. Sponsored
+// and non-sponsored urlbar results are enabled as defined by `sponsored` and
+// `nonsponsored`. `expectedIndex` is the expected index (relative to
+// `SUGGESTIONS_DATA`) of the returned urlbar result.
+let TESTS = {
+ aaa: {
+ // 0: sponsored
+ // 1: nonsponsored, score = 2x
+ expectedIndexes: [0, 1],
+ searches: [
+ {
+ sponsored: true,
+ nonsponsored: true,
+ expectedIndex: 1,
+ },
+ {
+ sponsored: false,
+ nonsponsored: true,
+ expectedIndex: 1,
+ },
+ {
+ sponsored: true,
+ nonsponsored: false,
+ expectedIndex: 0,
+ },
+ {
+ sponsored: false,
+ nonsponsored: false,
+ expectedIndex: undefined,
+ },
+ ],
+ },
+ bbb: {
+ // 1: nonsponsored, score = 2x
+ // 2: sponsored, score = 4x,
+ // 3: nonsponsored, score = 3x
+ expectedIndexes: [1, 2, 3],
+ searches: [
+ {
+ sponsored: true,
+ nonsponsored: true,
+ expectedIndex: 2,
+ },
+ {
+ sponsored: false,
+ nonsponsored: true,
+ expectedIndex: 3,
+ },
+ {
+ sponsored: true,
+ nonsponsored: false,
+ expectedIndex: 2,
+ },
+ {
+ sponsored: false,
+ nonsponsored: false,
+ expectedIndex: undefined,
+ },
+ ],
+ },
+ ccc: {
+ // 4: sponsored
+ expectedIndexes: [4],
+ searches: [
+ {
+ sponsored: true,
+ nonsponsored: true,
+ expectedIndex: 4,
+ },
+ {
+ sponsored: false,
+ nonsponsored: true,
+ expectedIndex: undefined,
+ },
+ {
+ sponsored: true,
+ nonsponsored: false,
+ expectedIndex: 4,
+ },
+ {
+ sponsored: false,
+ nonsponsored: false,
+ expectedIndex: undefined,
+ },
+ ],
+ },
+};
+
+add_task(async function () {
+ // Create results and suggestions based on `SUGGESTIONS_DATA`.
+ let qsResults = [];
+ let qsSuggestions = [];
+ let urlbarResults = [];
+ for (let i = 0; i < SUGGESTIONS_DATA.length; i++) {
+ let { keywords, isSponsored, score } = SUGGESTIONS_DATA[i];
+
+ // quick suggest result
+ let qsResult = {
+ keywords,
+ score,
+ id: i,
+ url: "http://example.com/" + i,
+ title: "Title " + i,
+ click_url: "http://example.com/click",
+ impression_url: "http://example.com/impression",
+ advertiser: "TestAdvertiser",
+ iab_category: isSponsored ? "22 - Shopping" : "5 - Education",
+ };
+ qsResults.push(qsResult);
+
+ // expected quick suggest suggestion
+ let qsSuggestion = {
+ ...qsResult,
+ score,
+ block_id: qsResult.id,
+ is_sponsored: isSponsored,
+ source: "remote-settings",
+ icon: null,
+ position: undefined,
+ provider: "AdmWikipedia",
+ };
+ delete qsSuggestion.keywords;
+ delete qsSuggestion.id;
+ qsSuggestions.push(qsSuggestion);
+
+ // expected urlbar result
+ urlbarResults.push({
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ isSponsored,
+ telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored",
+ sponsoredBlockId: qsResult.id,
+ url: qsResult.url,
+ originalUrl: qsResult.url,
+ displayUrl: qsResult.url,
+ title: qsResult.title,
+ sponsoredClickUrl: qsResult.click_url,
+ sponsoredImpressionUrl: qsResult.impression_url,
+ sponsoredAdvertiser: qsResult.advertiser,
+ sponsoredIabCategory: qsResult.iab_category,
+ icon: null,
+ descriptionL10n: isSponsored
+ ? { id: "urlbar-result-action-sponsored" }
+ : undefined,
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ source: "remote-settings",
+ provider: "AdmWikipedia",
+ },
+ });
+ }
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: qsResults,
+ },
+ ],
+ prefs: [
+ ["suggest.quicksuggest.sponsored", true],
+ ["suggest.quicksuggest.nonsponsored", true],
+ ],
+ });
+
+ // Run a test for each keyword.
+ for (let [keyword, test] of Object.entries(TESTS)) {
+ info("Running subtest " + JSON.stringify({ keyword, test }));
+
+ let { expectedIndexes, searches } = test;
+
+ // Call `query()`.
+ Assert.deepEqual(
+ await QuickSuggest.jsBackend.query(keyword),
+ expectedIndexes.map(i => ({
+ ...qsSuggestions[i],
+ full_keyword: keyword,
+ })),
+ `query() for keyword ${keyword}`
+ );
+
+ // Now do a urlbar search for the keyword with all possible combinations of
+ // sponsored and non-sponsored suggestions enabled and disabled.
+ for (let sponsored of [true, false]) {
+ for (let nonsponsored of [true, false]) {
+ // Find the matching `searches` object.
+ let search = searches.find(
+ s => s.sponsored == sponsored && s.nonsponsored == nonsponsored
+ );
+ Assert.ok(
+ search,
+ "Sanity check: Search test case specified for " +
+ JSON.stringify({ keyword, sponsored, nonsponsored })
+ );
+
+ info(
+ "Running urlbar search subtest " +
+ JSON.stringify({ keyword, expectedIndexes, search })
+ );
+
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", sponsored);
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", nonsponsored);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Set up the search and do it.
+ let context = createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ });
+
+ let matches = [];
+ if (search.expectedIndex !== undefined) {
+ matches.push({
+ ...urlbarResults[search.expectedIndex],
+ payload: {
+ ...urlbarResults[search.expectedIndex].payload,
+ qsSuggestion: keyword,
+ },
+ });
+ }
+
+ await check_results({ context, matches });
+ }
+ }
+
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ await QuickSuggestTestUtils.forceSync();
+ }
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js
new file mode 100644
index 0000000000..c01792e321
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_offlineDefault.js
@@ -0,0 +1,127 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests `UrlbarPrefs.updateFirefoxSuggestScenario` in isolation under the
+// assumption that the offline scenario should be enabled by default for US en.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Region: "resource://gre/modules/Region.sys.mjs",
+});
+
+// All the prefs that `updateFirefoxSuggestScenario` sets along with the
+// expected default-branch values when offline is enabled and when it's not
+// enabled.
+const PREFS = [
+ {
+ name: "browser.urlbar.quicksuggest.enabled",
+ get: "getBoolPref",
+ set: "setBoolPref",
+ expectedOfflineValue: true,
+ expectedOtherValue: false,
+ },
+ {
+ name: "browser.urlbar.quicksuggest.shouldShowOnboardingDialog",
+ get: "getBoolPref",
+ set: "setBoolPref",
+ expectedOfflineValue: false,
+ expectedOtherValue: true,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.nonsponsored",
+ get: "getBoolPref",
+ set: "setBoolPref",
+ expectedOfflineValue: true,
+ expectedOtherValue: false,
+ },
+ {
+ name: "browser.urlbar.suggest.quicksuggest.sponsored",
+ get: "getBoolPref",
+ set: "setBoolPref",
+ expectedOfflineValue: true,
+ expectedOtherValue: false,
+ },
+];
+
+add_setup(async () => {
+ await UrlbarTestUtils.initNimbusFeature();
+});
+
+add_task(async function test() {
+ let tests = [
+ { locale: "en-US", home: "US", expectedOfflineDefault: true },
+ { locale: "en-US", home: "CA", expectedOfflineDefault: false },
+ { locale: "en-CA", home: "US", expectedOfflineDefault: true },
+ { locale: "en-CA", home: "CA", expectedOfflineDefault: false },
+ { locale: "en-GB", home: "US", expectedOfflineDefault: true },
+ { locale: "en-GB", home: "GB", expectedOfflineDefault: false },
+ { locale: "de", home: "US", expectedOfflineDefault: false },
+ { locale: "de", home: "DE", expectedOfflineDefault: false },
+ ];
+ for (let { locale, home, expectedOfflineDefault } of tests) {
+ await doTest({ locale, home, expectedOfflineDefault });
+ }
+});
+
+/**
+ * Sets the app's locale and region, calls
+ * `UrlbarPrefs.updateFirefoxSuggestScenario`, and asserts that the pref values
+ * are correct.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {string} options.locale
+ * The locale to simulate.
+ * @param {string} options.home
+ * The "home" region to simulate.
+ * @param {boolean} options.expectedOfflineDefault
+ * The expected value of whether offline should be enabled by default given
+ * the locale and region.
+ */
+async function doTest({ locale, home, expectedOfflineDefault }) {
+ // Setup: Clear any user values and save original default-branch values.
+ for (let pref of PREFS) {
+ Services.prefs.clearUserPref(pref.name);
+ pref.originalDefault = Services.prefs
+ .getDefaultBranch(pref.name)
+ [pref.get]("");
+ }
+
+ // Set the region and locale, call the function, check the pref values.
+ Region._setHomeRegion(home, false);
+ await QuickSuggestTestUtils.withLocales([locale], async () => {
+ await UrlbarPrefs.updateFirefoxSuggestScenario();
+ for (let { name, get, expectedOfflineValue, expectedOtherValue } of PREFS) {
+ let expectedValue = expectedOfflineDefault
+ ? expectedOfflineValue
+ : expectedOtherValue;
+
+ // Check the default-branch value.
+ Assert.strictEqual(
+ Services.prefs.getDefaultBranch(name)[get](""),
+ expectedValue,
+ `Default pref value for ${name}, locale ${locale}, home ${home}`
+ );
+
+ // For good measure, also check the return value of `UrlbarPrefs.get`
+ // since we use it everywhere. The value should be the same as the
+ // default-branch value.
+ UrlbarPrefs.get(
+ name.replace("browser.urlbar.", ""),
+ expectedValue,
+ `UrlbarPrefs.get() value for ${name}, locale ${locale}, home ${home}`
+ );
+ }
+ });
+
+ // Teardown: Restore original default-branch values for the next task.
+ for (let { name, originalDefault, set } of PREFS) {
+ if (originalDefault === undefined) {
+ Services.prefs.deleteBranch(name);
+ } else {
+ Services.prefs.getDefaultBranch(name)[set]("", originalDefault);
+ }
+ }
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js
new file mode 100644
index 0000000000..29133a8579
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_pocket.js
@@ -0,0 +1,531 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests Pocket quick suggest results.
+
+"use strict";
+
+const LOW_KEYWORD = "low one two";
+const HIGH_KEYWORD = "high three";
+
+const REMOTE_SETTINGS_DATA = [
+ {
+ type: "pocket-suggestions",
+ attachment: [
+ {
+ url: "https://example.com/pocket-0",
+ title: "Pocket Suggestion 0",
+ description: "Pocket description 0",
+ lowConfidenceKeywords: [LOW_KEYWORD, "how to low"],
+ highConfidenceKeywords: [HIGH_KEYWORD],
+ score: 0.25,
+ },
+ {
+ url: "https://example.com/pocket-1",
+ title: "Pocket Suggestion 1",
+ description: "Pocket description 1",
+ lowConfidenceKeywords: ["other low"],
+ highConfidenceKeywords: ["another high"],
+ score: 0.25,
+ },
+ ],
+ },
+];
+
+add_setup(async () => {
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_DATA,
+ prefs: [
+ ["suggest.quicksuggest.nonsponsored", true],
+ ["pocket.featureGate", true],
+ ],
+ });
+});
+
+add_task(async function telemetryType() {
+ Assert.equal(
+ QuickSuggest.getFeature("PocketSuggestions").getSuggestionTelemetryType({}),
+ "pocket",
+ "Telemetry type should be 'pocket'"
+ );
+});
+
+// When non-sponsored suggestions are disabled, Pocket suggestions should be
+// disabled.
+add_tasks_with_rust(async function nonsponsoredDisabled() {
+ // Disable sponsored suggestions. Pocket suggestions are non-sponsored, so
+ // doing this should not prevent them from being enabled.
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+
+ // First make sure the suggestion is added when non-sponsored suggestions are
+ // enabled.
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeExpectedResult({ searchString: LOW_KEYWORD })],
+ });
+
+ // Now disable them.
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.clear("suggest.quicksuggest.sponsored");
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// When Pocket-specific preferences are disabled, suggestions should not be
+// added.
+add_tasks_with_rust(async function pocketSpecificPrefsDisabled() {
+ const prefs = ["suggest.pocket", "pocket.featureGate"];
+ for (const pref of prefs) {
+ // First make sure the suggestion is added.
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeExpectedResult({ searchString: LOW_KEYWORD })],
+ });
+
+ // Now disable the pref.
+ UrlbarPrefs.set(pref, false);
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Revert.
+ UrlbarPrefs.set(pref, true);
+ await QuickSuggestTestUtils.forceSync();
+ }
+});
+
+// Check wheather the Pocket suggestions will be shown by the setup of Nimbus
+// variable.
+add_tasks_with_rust(async function nimbus() {
+ // Disable the fature gate.
+ UrlbarPrefs.set("pocket.featureGate", false);
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Enable by Nimbus.
+ const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({
+ pocketFeatureGate: true,
+ });
+ await QuickSuggestTestUtils.forceSync();
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [makeExpectedResult({ searchString: LOW_KEYWORD })],
+ });
+ await cleanUpNimbusEnable();
+
+ // Enable locally.
+ UrlbarPrefs.set("pocket.featureGate", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Disable by Nimbus.
+ const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({
+ pocketFeatureGate: false,
+ });
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+ await cleanUpNimbusDisable();
+
+ // Revert.
+ UrlbarPrefs.set("pocket.featureGate", true);
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// The suggestion should be shown as a top pick when a high-confidence keyword
+// is matched.
+add_tasks_with_rust(async function topPick() {
+ await check_results({
+ context: createContext(HIGH_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({ searchString: HIGH_KEYWORD, isTopPick: true }),
+ ],
+ });
+});
+
+// Low-confidence keywords should do prefix matching starting at the first word.
+add_tasks_with_rust(async function lowPrefixes() {
+ // search string -> should match
+ let tests = {
+ l: false,
+ lo: false,
+ low: true,
+ "low ": true,
+ "low o": true,
+ "low on": true,
+ "low one": true,
+ "low one ": true,
+ "low one t": true,
+ "low one tw": true,
+ "low one two": true,
+ "low one two ": false,
+ };
+ for (let [searchString, shouldMatch] of Object.entries(tests)) {
+ info("Doing search: " + JSON.stringify({ searchString, shouldMatch }));
+ await check_results({
+ context: createContext(searchString, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: shouldMatch
+ ? [makeExpectedResult({ searchString, fullKeyword: LOW_KEYWORD })]
+ : [],
+ });
+ }
+});
+
+// Low-confidence keywords that start with "how to" should do prefix matching
+// starting at "how to" instead of the first word.
+//
+// Note: The Rust implementation doesn't support this.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function lowPrefixes_howTo() {
+ // search string -> should match
+ let tests = {
+ h: false,
+ ho: false,
+ how: false,
+ "how ": false,
+ "how t": false,
+ "how to": true,
+ "how to ": true,
+ "how to l": true,
+ "how to lo": true,
+ "how to low": true,
+ };
+ for (let [searchString, shouldMatch] of Object.entries(tests)) {
+ info("Doing search: " + JSON.stringify({ searchString, shouldMatch }));
+ await check_results({
+ context: createContext(searchString, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: shouldMatch
+ ? [makeExpectedResult({ searchString, fullKeyword: "how to low" })]
+ : [],
+ });
+ }
+ }
+);
+
+// High-confidence keywords should not do prefix matching at all.
+add_tasks_with_rust(async function highPrefixes() {
+ // search string -> should match
+ let tests = {
+ h: false,
+ hi: false,
+ hig: false,
+ high: false,
+ "high ": false,
+ "high t": false,
+ "high th": false,
+ "high thr": false,
+ "high thre": false,
+ "high three": true,
+ "high three ": false,
+ };
+ for (let [searchString, shouldMatch] of Object.entries(tests)) {
+ info("Doing search: " + JSON.stringify({ searchString, shouldMatch }));
+ await check_results({
+ context: createContext(searchString, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: shouldMatch
+ ? [
+ makeExpectedResult({
+ searchString,
+ fullKeyword: HIGH_KEYWORD,
+ isTopPick: true,
+ }),
+ ]
+ : [],
+ });
+ }
+});
+
+// Keyword matching should be case insenstive.
+add_tasks_with_rust(async function uppercase() {
+ await check_results({
+ context: createContext(LOW_KEYWORD.toUpperCase(), {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ searchString: LOW_KEYWORD.toUpperCase(),
+ fullKeyword: LOW_KEYWORD,
+ }),
+ ],
+ });
+ await check_results({
+ context: createContext(HIGH_KEYWORD.toUpperCase(), {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ searchString: HIGH_KEYWORD.toUpperCase(),
+ fullKeyword: HIGH_KEYWORD,
+ isTopPick: true,
+ }),
+ ],
+ });
+});
+
+// Tests the "Not relevant" command: a dismissed suggestion shouldn't be added.
+add_tasks_with_rust(async function notRelevant() {
+ let result = makeExpectedResult({ searchString: LOW_KEYWORD });
+
+ info("Triggering the 'Not relevant' command");
+ QuickSuggest.getFeature("PocketSuggestions").handleCommand(
+ {
+ controller: { removeResult() {} },
+ },
+ result,
+ "not_relevant"
+ );
+ await QuickSuggest.blockedSuggestions._test_readyPromise;
+
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(result.payload.originalUrl),
+ "The result's URL should be blocked"
+ );
+
+ info("Doing search for blocked suggestion");
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ info("Doing search for blocked suggestion using high-confidence keyword");
+ await check_results({
+ context: createContext(HIGH_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ info("Doing search for a suggestion that wasn't blocked");
+ await check_results({
+ context: createContext("other low", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ searchString: "other low",
+ suggestion: REMOTE_SETTINGS_DATA[0].attachment[1],
+ }),
+ ],
+ });
+
+ info("Clearing blocked suggestions");
+ await QuickSuggest.blockedSuggestions.clear();
+
+ info("Doing search for unblocked suggestion");
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [result],
+ });
+});
+
+// Tests the "Not interested" command: all Pocket suggestions should be disabled
+// and not added anymore.
+add_tasks_with_rust(async function notInterested() {
+ let result = makeExpectedResult({ searchString: LOW_KEYWORD });
+
+ info("Triggering the 'Not interested' command");
+ QuickSuggest.getFeature("PocketSuggestions").handleCommand(
+ {
+ controller: { removeResult() {} },
+ },
+ result,
+ "not_interested"
+ );
+
+ Assert.ok(
+ !UrlbarPrefs.get("suggest.pocket"),
+ "Pocket suggestions should be disabled"
+ );
+
+ info("Doing search for the suggestion the command was used on");
+ await check_results({
+ context: createContext(LOW_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ info("Doing search for another Pocket suggestion");
+ await check_results({
+ context: createContext("other low", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ UrlbarPrefs.clear("suggest.pocket");
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// Tests the "show less frequently" behavior.
+add_tasks_with_rust(async function showLessFrequently() {
+ await doShowLessFrequentlyTests({
+ feature: QuickSuggest.getFeature("PocketSuggestions"),
+ showLessFrequentlyCountPref: "pocket.showLessFrequentlyCount",
+ nimbusCapVariable: "pocketShowLessFrequentlyCap",
+ expectedResult: searchString =>
+ makeExpectedResult({ searchString, fullKeyword: LOW_KEYWORD }),
+ keyword: LOW_KEYWORD,
+ });
+});
+
+// The `Pocket` Rust provider should be passed to the Rust component when
+// querying depending on whether Pocket suggestions are enabled.
+add_task(async function rustProviders() {
+ // TODO bug 1874074: The Rust component fetches Pocket suggestions when the
+ // AMO provider is specified regardless of whether the Pocket provider is
+ // specified. AMO suggestions are enabled by default, so disable them first so
+ // that the Rust backend does not pass in the AMO provider.
+ UrlbarPrefs.set("suggest.addons", false);
+
+ await doRustProvidersTests({
+ searchString: LOW_KEYWORD,
+ tests: [
+ {
+ prefs: {
+ "suggest.pocket": true,
+ },
+ expectedUrls: ["https://example.com/pocket-0"],
+ },
+ {
+ prefs: {
+ "suggest.pocket": false,
+ },
+ expectedUrls: [],
+ },
+ ],
+ });
+
+ UrlbarPrefs.clear("suggest.addons");
+ UrlbarPrefs.clear("suggest.pocket");
+ await QuickSuggestTestUtils.forceSync();
+});
+
+function makeExpectedResult({
+ searchString,
+ fullKeyword = searchString,
+ suggestion = REMOTE_SETTINGS_DATA[0].attachment[0],
+ source = "remote-settings",
+ isTopPick = false,
+} = {}) {
+ if (
+ source == "remote-settings" &&
+ UrlbarPrefs.get("quicksuggest.rustEnabled")
+ ) {
+ source = "rust";
+ }
+
+ let provider;
+ let keywordSubstringNotTyped = fullKeyword.substring(searchString.length);
+ let description = suggestion.description;
+ switch (source) {
+ case "remote-settings":
+ provider = "PocketSuggestions";
+ break;
+ case "rust":
+ provider = "Pocket";
+ // Rust suggestions currently do not include full keyword or description.
+ keywordSubstringNotTyped = "";
+ description = suggestion.title;
+ break;
+ case "merino":
+ provider = "pocket";
+ break;
+ }
+
+ let url = new URL(suggestion.url);
+ url.searchParams.set("utm_medium", "firefox-desktop");
+ url.searchParams.set("utm_source", "firefox-suggest");
+ url.searchParams.set("utm_campaign", "pocket-collections-in-the-address-bar");
+ url.searchParams.set("utm_content", "treatment");
+
+ return {
+ isBestMatch: isTopPick,
+ suggestedIndex: isTopPick ? 1 : -1,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK,
+ heuristic: false,
+ payload: {
+ source,
+ provider,
+ telemetryType: "pocket",
+ title: suggestion.title,
+ url: url.href,
+ displayUrl: url.href.replace(/^https:\/\//, ""),
+ originalUrl: suggestion.url,
+ description: isTopPick ? description : "",
+ icon: isTopPick
+ ? "chrome://global/skin/icons/pocket.svg"
+ : "chrome://global/skin/icons/pocket-favicon.ico",
+ helpUrl: QuickSuggest.HELP_URL,
+ shouldShowUrl: true,
+ bottomTextL10n: {
+ id: "firefox-suggest-pocket-bottom-text",
+ args: {
+ keywordSubstringTyped: searchString,
+ keywordSubstringNotTyped,
+ },
+ },
+ },
+ };
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js
new file mode 100644
index 0000000000..d1845a9b22
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_positionInSuggestions.js
@@ -0,0 +1,487 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests for quick suggest result position specified in suggestions.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ UrlbarProviderHeuristicFallback:
+ "resource:///modules/UrlbarProviderHeuristicFallback.sys.mjs",
+ UrlbarProviderPlaces: "resource:///modules/UrlbarProviderPlaces.sys.mjs",
+ UrlbarProviderTabToSearch:
+ "resource:///modules/UrlbarProviderTabToSearch.sys.mjs",
+});
+
+const SPONSORED_SECOND_POSITION_RESULT = {
+ id: 1,
+ url: "http://example.com/?q=sponsored-second",
+ title: "sponsored second",
+ keywords: ["s-s"],
+ click_url: "http://click.reporting.test.com/",
+ impression_url: "http://impression.reporting.test.com/",
+ advertiser: "TestAdvertiser",
+ iab_category: "22 - Shopping",
+ position: 1,
+};
+const SPONSORED_NORMAL_POSITION_RESULT = {
+ id: 2,
+ url: "http://example.com/?q=sponsored-normal",
+ title: "sponsored normal",
+ keywords: ["s-n"],
+ click_url: "http://click.reporting.test.com/",
+ impression_url: "http://impression.reporting.test.com/",
+ advertiser: "TestAdvertiser",
+ iab_category: "22 - Shopping",
+};
+const NONSPONSORED_SECOND_POSITION_RESULT = {
+ id: 3,
+ url: "http://example.com/?q=nonsponsored-second",
+ title: "nonsponsored second",
+ keywords: ["n-s"],
+ click_url: "http://click.reporting.test.com/nonsponsored",
+ impression_url: "http://impression.reporting.test.com/nonsponsored",
+ advertiser: "TestAdvertiserNonSponsored",
+ iab_category: "5 - Education",
+ position: 1,
+};
+const NONSPONSORED_NORMAL_POSITION_RESULT = {
+ id: 4,
+ url: "http://example.com/?q=nonsponsored-normal",
+ title: "nonsponsored normal",
+ keywords: ["n-n"],
+ click_url: "http://click.reporting.test.com/nonsponsored",
+ impression_url: "http://impression.reporting.test.com/nonsponsored",
+ advertiser: "TestAdvertiserNonSponsored",
+ iab_category: "5 - Education",
+};
+const FIRST_POSITION_RESULT = {
+ id: 5,
+ url: "http://example.com/?q=first-position",
+ title: "first position suggest",
+ keywords: ["first-position"],
+ click_url: "http://click.reporting.test.com/first-position",
+ impression_url: "http://impression.reporting.test.com/first-position",
+ advertiser: "TestAdvertiserFirstPositionQuickSuggest",
+ iab_category: "22 - Shopping",
+ position: 0,
+};
+const SECOND_POSITION_RESULT = {
+ id: 6,
+ url: "http://example.com/?q=second-position",
+ title: "second position suggest",
+ keywords: ["second-position"],
+ click_url: "http://click.reporting.test.com/second-position",
+ impression_url: "http://impression.reporting.test.com/second-position",
+ advertiser: "TestAdvertiserSecondPositionQuickSuggest",
+ iab_category: "22 - Shopping",
+ position: 1,
+};
+const THIRD_POSITION_RESULT = {
+ id: 7,
+ url: "http://example.com/?q=third-position",
+ title: "third position suggest",
+ keywords: ["third-position"],
+ click_url: "http://click.reporting.test.com/third-position",
+ impression_url: "http://impression.reporting.test.com/third-position",
+ advertiser: "TestAdvertiserThirdPositionQuickSuggest",
+ iab_category: "22 - Shopping",
+ position: 2,
+};
+
+const TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST =
+ "first-position.example.com";
+const TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST =
+ "second-position.example.com";
+
+const SECOND_POSITION_INTERVENTION_RESULT = new UrlbarResult(
+ UrlbarUtils.RESULT_TYPE.URL,
+ UrlbarUtils.RESULT_SOURCE.HISTORY,
+ { url: "http://mozilla.org/a" }
+);
+SECOND_POSITION_INTERVENTION_RESULT.suggestedIndex = 1;
+const SECOND_POSITION_INTERVENTION_RESULT_PROVIDER =
+ new UrlbarTestUtils.TestProvider({
+ results: [SECOND_POSITION_INTERVENTION_RESULT],
+ priority: 0,
+ name: "second_position_intervention_provider",
+ });
+
+const EXPECTED_GENERAL_HEURISTIC_RESULT = {
+ providerName: UrlbarProviderHeuristicFallback.name,
+ type: UrlbarUtils.RESULT_TYPE.SEARCH,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: true,
+};
+
+const EXPECTED_GENERAL_PLACES_RESULT = {
+ providerName: UrlbarProviderPlaces.name,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.HISTORY,
+ heuristic: false,
+};
+
+const EXPECTED_GENERAL_TABTOSEARCH_RESULT = {
+ providerName: UrlbarProviderTabToSearch.name,
+ type: UrlbarUtils.RESULT_TYPE.DYNAMIC,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+};
+
+const EXPECTED_GENERAL_INTERVENTION_RESULT = {
+ providerName: SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.HISTORY,
+ heuristic: false,
+};
+
+function createExpectedQuickSuggestResult(suggest) {
+ let isSponsored = suggest.iab_category !== "5 - Education";
+ return {
+ providerName: UrlbarProviderQuickSuggest.name,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored",
+ qsSuggestion: suggest.keywords[0],
+ title: suggest.title,
+ url: suggest.url,
+ originalUrl: suggest.url,
+ icon: null,
+ sponsoredImpressionUrl: suggest.impression_url,
+ sponsoredClickUrl: suggest.click_url,
+ sponsoredBlockId: suggest.id,
+ sponsoredAdvertiser: suggest.advertiser,
+ sponsoredIabCategory: suggest.iab_category,
+ isSponsored,
+ descriptionL10n: isSponsored
+ ? { id: "urlbar-result-action-sponsored" }
+ : undefined,
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ displayUrl: suggest.url,
+ source: "remote-settings",
+ provider: "AdmWikipedia",
+ },
+ };
+}
+
+const TEST_CASES = [
+ {
+ description: "Test for second placable sponsored suggest",
+ input: SPONSORED_SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderPlaces.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT),
+ EXPECTED_GENERAL_PLACES_RESULT,
+ ],
+ },
+ {
+ description: "Test for normal sponsored suggest",
+ input: SPONSORED_NORMAL_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderPlaces.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ EXPECTED_GENERAL_PLACES_RESULT,
+ createExpectedQuickSuggestResult(SPONSORED_NORMAL_POSITION_RESULT),
+ ],
+ },
+ {
+ description: "Test for second placable nonsponsored suggest",
+ input: NONSPONSORED_SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderPlaces.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ createExpectedQuickSuggestResult(NONSPONSORED_SECOND_POSITION_RESULT),
+ EXPECTED_GENERAL_PLACES_RESULT,
+ ],
+ },
+ {
+ description: "Test for normal nonsponsored suggest",
+ input: NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderPlaces.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ EXPECTED_GENERAL_PLACES_RESULT,
+ createExpectedQuickSuggestResult(NONSPONSORED_NORMAL_POSITION_RESULT),
+ ],
+ },
+ {
+ description:
+ "Test for second placable sponsored suggest but secondPosition pref is disabled",
+ input: SPONSORED_SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": false,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderPlaces.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ EXPECTED_GENERAL_PLACES_RESULT,
+ createExpectedQuickSuggestResult(SPONSORED_SECOND_POSITION_RESULT),
+ ],
+ },
+ {
+ description: "Test the results with multi providers having same index",
+ input: SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderTabToSearch.name,
+ SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_TABTOSEARCH_RESULT,
+ createExpectedQuickSuggestResult(SECOND_POSITION_RESULT),
+ EXPECTED_GENERAL_INTERVENTION_RESULT,
+ ],
+ },
+ {
+ description: "Test the results with tab-to-search",
+ input: SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderTabToSearch.name,
+ UrlbarProviderQuickSuggest.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_TABTOSEARCH_RESULT,
+ createExpectedQuickSuggestResult(SECOND_POSITION_RESULT),
+ ],
+ },
+ {
+ description: "Test the results with another intervention",
+ input: SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderQuickSuggest.name,
+ SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name,
+ ],
+ expected: [
+ createExpectedQuickSuggestResult(SECOND_POSITION_RESULT),
+ EXPECTED_GENERAL_INTERVENTION_RESULT,
+ ],
+ },
+ {
+ description: "Test the results with heuristic and tab-to-search",
+ input: SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderTabToSearch.name,
+ UrlbarProviderQuickSuggest.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ EXPECTED_GENERAL_TABTOSEARCH_RESULT,
+ createExpectedQuickSuggestResult(SECOND_POSITION_RESULT),
+ ],
+ },
+ {
+ description: "Test the results with heuristic tab-to-search and places",
+ input: SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderTabToSearch.name,
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderPlaces.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ EXPECTED_GENERAL_TABTOSEARCH_RESULT,
+ createExpectedQuickSuggestResult(SECOND_POSITION_RESULT),
+ EXPECTED_GENERAL_PLACES_RESULT,
+ ],
+ },
+ {
+ description: "Test the results with heuristic and another intervention",
+ input: SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderQuickSuggest.name,
+ SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ createExpectedQuickSuggestResult(SECOND_POSITION_RESULT),
+ EXPECTED_GENERAL_INTERVENTION_RESULT,
+ ],
+ },
+ {
+ description:
+ "Test the results with heuristic, another intervention and places",
+ input: SECOND_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderHeuristicFallback.name,
+ UrlbarProviderQuickSuggest.name,
+ SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name,
+ UrlbarProviderPlaces.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_HEURISTIC_RESULT,
+ createExpectedQuickSuggestResult(SECOND_POSITION_RESULT),
+ EXPECTED_GENERAL_INTERVENTION_RESULT,
+ EXPECTED_GENERAL_PLACES_RESULT,
+ ],
+ },
+ {
+ description: "Test for 0 indexed quick suggest",
+ input: FIRST_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderTabToSearch.name,
+ UrlbarProviderQuickSuggest.name,
+ ],
+ expected: [
+ createExpectedQuickSuggestResult(FIRST_POSITION_RESULT),
+ EXPECTED_GENERAL_TABTOSEARCH_RESULT,
+ ],
+ },
+ {
+ description: "Test for 2 indexed quick suggest",
+ input: THIRD_POSITION_RESULT.keywords[0],
+ prefs: {
+ "quicksuggest.allowPositionInSuggestions": true,
+ },
+ providers: [
+ UrlbarProviderQuickSuggest.name,
+ SECOND_POSITION_INTERVENTION_RESULT_PROVIDER.name,
+ ],
+ expected: [
+ EXPECTED_GENERAL_INTERVENTION_RESULT,
+ createExpectedQuickSuggestResult(THIRD_POSITION_RESULT),
+ ],
+ },
+];
+
+add_setup(async function () {
+ // Setup for quick suggest result.
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: [
+ SPONSORED_SECOND_POSITION_RESULT,
+ SPONSORED_NORMAL_POSITION_RESULT,
+ NONSPONSORED_SECOND_POSITION_RESULT,
+ NONSPONSORED_NORMAL_POSITION_RESULT,
+ FIRST_POSITION_RESULT,
+ SECOND_POSITION_RESULT,
+ THIRD_POSITION_RESULT,
+ ],
+ },
+ ],
+ prefs: [
+ ["suggest.quicksuggest.sponsored", true],
+ ["suggest.quicksuggest.nonsponsored", true],
+ ],
+ });
+
+ // Setup for places result.
+ await PlacesUtils.history.clear();
+ await PlacesTestUtils.addVisits([
+ "http://example.com/" + SPONSORED_SECOND_POSITION_RESULT.keywords[0],
+ "http://example.com/" + SPONSORED_NORMAL_POSITION_RESULT.keywords[0],
+ "http://example.com/" + NONSPONSORED_SECOND_POSITION_RESULT.keywords[0],
+ "http://example.com/" + NONSPONSORED_NORMAL_POSITION_RESULT.keywords[0],
+ "http://example.com/" + SECOND_POSITION_RESULT.keywords[0],
+ ]);
+
+ // Setup for tab-to-search result.
+ await SearchTestUtils.installSearchExtension({
+ name: "first",
+ search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_FIRST_POSITION_TEST}/`,
+ });
+ await SearchTestUtils.installSearchExtension({
+ name: "second",
+ search_url: `https://${TABTOSEARCH_ENGINE_DOMAIN_FOR_SECOND_POSITION_TEST}/`,
+ });
+
+ /// Setup for another intervention result.
+ UrlbarProvidersManager.registerProvider(
+ SECOND_POSITION_INTERVENTION_RESULT_PROVIDER
+ );
+});
+
+add_task(async function basic() {
+ for (const { description, input, prefs, providers, expected } of TEST_CASES) {
+ info(description);
+
+ for (let name in prefs) {
+ UrlbarPrefs.set(name, prefs[name]);
+ }
+
+ const context = createContext(input, {
+ providers,
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: expected,
+ });
+
+ for (let name in prefs) {
+ UrlbarPrefs.clear(name);
+ }
+ }
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js
new file mode 100644
index 0000000000..224dd6cb22
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_scoreMap.js
@@ -0,0 +1,670 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests the `quickSuggestScoreMap` Nimbus variable that assigns scores to
+// specified types of quick suggest suggestions. The scores in the map should
+// override the scores in the individual suggestion objects so that experiments
+// can fully control the relative ranking of suggestions.
+
+"use strict";
+
+const { DEFAULT_SUGGESTION_SCORE } = UrlbarProviderQuickSuggest;
+
+const REMOTE_SETTINGS_RECORDS = [
+ {
+ type: "data",
+ attachment: [
+ // sponsored without score
+ QuickSuggestTestUtils.ampRemoteSettings({
+ score: undefined,
+ keywords: [
+ "sponsored without score",
+ "sponsored without score, nonsponsored without score",
+ "sponsored without score, nonsponsored with score",
+ "sponsored without score, addon without score",
+ ],
+ url: "https://example.com/sponsored-without-score",
+ title: "Sponsored without score",
+ }),
+ // sponsored with score
+ QuickSuggestTestUtils.ampRemoteSettings({
+ score: 2 * DEFAULT_SUGGESTION_SCORE,
+ keywords: [
+ "sponsored with score",
+ "sponsored with score, nonsponsored without score",
+ "sponsored with score, nonsponsored with score",
+ "sponsored with score, addon with score",
+ ],
+ url: "https://example.com/sponsored-with-score",
+ title: "Sponsored with score",
+ }),
+ // nonsponsored without score
+ QuickSuggestTestUtils.wikipediaRemoteSettings({
+ score: undefined,
+ keywords: [
+ "nonsponsored without score",
+ "sponsored without score, nonsponsored without score",
+ "sponsored with score, nonsponsored without score",
+ ],
+ url: "https://example.com/nonsponsored-without-score",
+ title: "Nonsponsored without score",
+ }),
+ // nonsponsored with score
+ QuickSuggestTestUtils.wikipediaRemoteSettings({
+ score: 2 * DEFAULT_SUGGESTION_SCORE,
+ keywords: [
+ "nonsponsored with score",
+ "sponsored without score, nonsponsored with score",
+ "sponsored with score, nonsponsored with score",
+ ],
+ url: "https://example.com/nonsponsored-with-score",
+ title: "Nonsponsored with score",
+ }),
+ ],
+ },
+ {
+ type: "amo-suggestions",
+ attachment: [
+ // addon with score
+ QuickSuggestTestUtils.amoRemoteSettings({
+ score: 2 * DEFAULT_SUGGESTION_SCORE,
+ keywords: [
+ "addon with score",
+ "sponsored with score, addon with score",
+ ],
+ url: "https://example.com/addon-with-score",
+ title: "Addon with score",
+ }),
+ ],
+ },
+];
+
+const ADM_RECORD = REMOTE_SETTINGS_RECORDS[0];
+const SPONSORED_WITHOUT_SCORE = ADM_RECORD.attachment[0];
+const SPONSORED_WITH_SCORE = ADM_RECORD.attachment[1];
+const NONSPONSORED_WITHOUT_SCORE = ADM_RECORD.attachment[2];
+const NONSPONSORED_WITH_SCORE = ADM_RECORD.attachment[3];
+
+const ADDON_RECORD = REMOTE_SETTINGS_RECORDS[1];
+const ADDON_WITH_SCORE = ADDON_RECORD.attachment[0];
+
+const MERINO_SPONSORED_SUGGESTION = {
+ provider: "adm",
+ score: DEFAULT_SUGGESTION_SCORE,
+ iab_category: "22 - Shopping",
+ is_sponsored: true,
+ keywords: ["test"],
+ full_keyword: "test",
+ block_id: 1,
+ url: "https://example.com/merino-sponsored",
+ title: "Merino sponsored",
+ click_url: "https://example.com/click",
+ impression_url: "https://example.com/impression",
+ advertiser: "TestAdvertiser",
+ icon: "1234",
+};
+
+const MERINO_ADDON_SUGGESTION = {
+ provider: "amo",
+ score: DEFAULT_SUGGESTION_SCORE,
+ keywords: ["test"],
+ icon: "https://example.com/addon.svg",
+ url: "https://example.com/merino-addon",
+ title: "Merino addon",
+ description: "Merino addon",
+ custom_details: {
+ amo: {
+ guid: "merino-addon@example.com",
+ rating: "4.7",
+ number_of_ratings: "1256",
+ },
+ },
+};
+
+const MERINO_UNKNOWN_SUGGESTION = {
+ provider: "some_unknown_provider",
+ score: DEFAULT_SUGGESTION_SCORE,
+ keywords: ["test"],
+ url: "https://example.com/merino-unknown",
+ title: "Merino unknown",
+};
+
+add_setup(async function init() {
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_RECORDS,
+ merinoSuggestions: [],
+ prefs: [
+ ["suggest.quicksuggest.sponsored", true],
+ ["suggest.quicksuggest.nonsponsored", true],
+ ],
+ });
+});
+
+add_task(async function sponsoredWithout_nonsponsoredWithout_sponsoredWins() {
+ let keyword = "sponsored without score, nonsponsored without score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITHOUT_SCORE,
+ }),
+ });
+});
+
+add_task(
+ async function sponsoredWithout_nonsponsoredWithout_nonsponsoredWins() {
+ let keyword = "sponsored without score, nonsponsored without score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_nonsponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedWikipediaResult({
+ keyword,
+ suggestion: NONSPONSORED_WITHOUT_SCORE,
+ }),
+ });
+ }
+);
+
+add_task(
+ async function sponsoredWithout_nonsponsoredWithout_sponsoredWins_both() {
+ let keyword = "sponsored without score, nonsponsored without score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ adm_nonsponsored: score / 2,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITHOUT_SCORE,
+ }),
+ });
+ }
+);
+
+add_task(
+ async function sponsoredWithout_nonsponsoredWithout_nonsponsoredWins_both() {
+ let keyword = "sponsored without score, nonsponsored without score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_nonsponsored: score,
+ adm_sponsored: score / 2,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedWikipediaResult({
+ keyword,
+ suggestion: NONSPONSORED_WITHOUT_SCORE,
+ }),
+ });
+ }
+);
+
+add_task(async function sponsoredWith_nonsponsoredWith_sponsoredWins() {
+ let keyword = "sponsored with score, nonsponsored with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWith_nonsponsoredWith_nonsponsoredWins() {
+ let keyword = "sponsored with score, nonsponsored with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_nonsponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedWikipediaResult({
+ keyword,
+ suggestion: NONSPONSORED_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWith_nonsponsoredWith_sponsoredWins_both() {
+ let keyword = "sponsored with score, nonsponsored with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ adm_nonsponsored: score / 2,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWith_nonsponsoredWith_nonsponsoredWins_both() {
+ let keyword = "sponsored with score, nonsponsored with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_nonsponsored: score,
+ adm_sponsored: score / 2,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedWikipediaResult({
+ keyword,
+ suggestion: NONSPONSORED_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWithout_addonWithout_sponsoredWins() {
+ let keyword = "sponsored without score, addon without score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITHOUT_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWithout_addonWithout_sponsoredWins_both() {
+ let keyword = "sponsored without score, addon without score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ amo: score / 2,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITHOUT_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWith_addonWith_sponsoredWins() {
+ let keyword = "sponsored with score, addon with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWith_addonWith_addonWins() {
+ let keyword = "sponsored with score, addon with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ amo: score,
+ },
+ expectedFeatureName: "AddonSuggestions",
+ expectedScore: score,
+ expectedResult: makeExpectedAddonResult({
+ suggestion: ADDON_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWith_addonWith_sponsoredWins_both() {
+ let keyword = "sponsored with score, addon with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: score,
+ amo: score / 2,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function sponsoredWith_addonWith_addonWins_both() {
+ let keyword = "sponsored with score, addon with score";
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword,
+ scoreMap: {
+ amo: score,
+ adm_sponsored: score / 2,
+ },
+ expectedFeatureName: "AddonSuggestions",
+ expectedScore: score,
+ expectedResult: makeExpectedAddonResult({
+ suggestion: ADDON_WITH_SCORE,
+ }),
+ });
+});
+
+add_task(async function merino_sponsored_addon_sponsoredWins() {
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([]);
+
+ MerinoTestUtils.server.response.body.suggestions = [
+ MERINO_SPONSORED_SUGGESTION,
+ MERINO_ADDON_SUGGESTION,
+ ];
+
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword: "test",
+ scoreMap: {
+ adm_sponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword: "test",
+ suggestion: MERINO_SPONSORED_SUGGESTION,
+ source: "merino",
+ provider: "adm",
+ requestId: MerinoTestUtils.server.response.body.request_id,
+ }),
+ });
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS);
+});
+
+add_task(async function merino_sponsored_addon_addonWins() {
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([]);
+
+ MerinoTestUtils.server.response.body.suggestions = [
+ MERINO_SPONSORED_SUGGESTION,
+ MERINO_ADDON_SUGGESTION,
+ ];
+
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword: "test",
+ scoreMap: {
+ amo: score,
+ },
+ expectedFeatureName: "AddonSuggestions",
+ expectedScore: score,
+ expectedResult: makeExpectedAddonResult({
+ suggestion: MERINO_ADDON_SUGGESTION,
+ source: "merino",
+ provider: "amo",
+ requestId: MerinoTestUtils.server.response.body.request_id,
+ }),
+ });
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS);
+});
+
+add_task(async function merino_sponsored_unknown_sponsoredWins() {
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([]);
+
+ MerinoTestUtils.server.response.body.suggestions = [
+ MERINO_SPONSORED_SUGGESTION,
+ MERINO_UNKNOWN_SUGGESTION,
+ ];
+
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword: "test",
+ scoreMap: {
+ adm_sponsored: score,
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: score,
+ expectedResult: makeExpectedAdmResult({
+ keyword: "test",
+ suggestion: MERINO_SPONSORED_SUGGESTION,
+ source: "merino",
+ provider: "adm",
+ requestId: MerinoTestUtils.server.response.body.request_id,
+ }),
+ });
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS);
+});
+
+add_task(async function merino_sponsored_unknown_unknownWins() {
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([]);
+
+ MerinoTestUtils.server.response.body.suggestions = [
+ MERINO_SPONSORED_SUGGESTION,
+ MERINO_UNKNOWN_SUGGESTION,
+ ];
+
+ let score = 10 * DEFAULT_SUGGESTION_SCORE;
+ await doTest({
+ keyword: "test",
+ scoreMap: {
+ [MERINO_UNKNOWN_SUGGESTION.provider]: score,
+ },
+ expectedFeatureName: null,
+ expectedScore: score,
+ expectedResult: makeExpectedDefaultResult({
+ suggestion: MERINO_UNKNOWN_SUGGESTION,
+ }),
+ });
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords(REMOTE_SETTINGS_RECORDS);
+});
+
+add_task(async function stringValue() {
+ let keyword = "sponsored with score, nonsponsored with score";
+ await doTest({
+ keyword,
+ scoreMap: {
+ adm_sponsored: "123.456",
+ },
+ expectedFeatureName: "AdmWikipedia",
+ expectedScore: 123.456,
+ expectedResult: makeExpectedAdmResult({
+ keyword,
+ suggestion: SPONSORED_WITH_SCORE,
+ }),
+ });
+});
+
+/**
+ * Sets up Nimbus with a `quickSuggestScoreMap` variable value, does a search,
+ * and makes sure the expected result is shown and the expected score is set on
+ * the suggestion.
+ *
+ * @param {object} options
+ * Options object.
+ * @param {string} options.keyword
+ * The search string. This should be equal to a keyword from one or more
+ * suggestions.
+ * @param {object} options.scoreMap
+ * The value to set for the `quickSuggestScoreMap` variable.
+ * @param {string} options.expectedFeatureName
+ * The name of the `BaseFeature` instance that is expected to create the
+ * `UrlbarResult` that's shown. If the suggestion is intentionally from an
+ * unknown Merino provider and therefore the quick suggest provider is
+ * expected to create a default result for it, set this to null.
+ * @param {UrlbarResultstring} options.expectedResult
+ * The `UrlbarResult` that's expected to be shown.
+ * @param {number} options.expectedScore
+ * The final `score` value that's expected to be defined on the suggestion
+ * object.
+ */
+async function doTest({
+ keyword,
+ scoreMap,
+ expectedFeatureName,
+ expectedResult,
+ expectedScore,
+}) {
+ let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({
+ quickSuggestScoreMap: scoreMap,
+ });
+
+ // Stub the expected feature's `makeResult()` so we can see the value of the
+ // passed-in suggestion's score. If the suggestion's type is in the score map,
+ // the provider will set its score before calling `makeResult()`.
+ let actualScore;
+ let sandbox;
+ if (expectedFeatureName) {
+ sandbox = sinon.createSandbox();
+ let feature = QuickSuggest.getFeature(expectedFeatureName);
+ let stub = sandbox
+ .stub(feature, "makeResult")
+ .callsFake((queryContext, suggestion, searchString) => {
+ actualScore = suggestion.score;
+ return stub.wrappedMethod.call(
+ feature,
+ queryContext,
+ suggestion,
+ searchString
+ );
+ });
+ }
+
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [expectedResult],
+ });
+
+ if (expectedFeatureName) {
+ Assert.equal(
+ actualScore,
+ expectedScore,
+ "Suggestion score should be set correctly"
+ );
+ sandbox.restore();
+ }
+
+ await cleanUpNimbus();
+}
+
+function makeExpectedAdmResult({
+ suggestion,
+ keyword,
+ source,
+ provider,
+ requestId,
+}) {
+ return makeAmpResult({
+ keyword,
+ source,
+ provider,
+ requestId,
+ title: suggestion.title,
+ url: suggestion.url,
+ originalUrl: suggestion.url,
+ impressionUrl: suggestion.impression_url,
+ clickUrl: suggestion.click_url,
+ blockId: suggestion.id,
+ advertiser: suggestion.advertiser,
+ icon: suggestion.icon,
+ });
+}
+
+function makeExpectedWikipediaResult({ suggestion, keyword, source }) {
+ return makeWikipediaResult({
+ keyword,
+ source,
+ title: suggestion.title,
+ url: suggestion.url,
+ originalUrl: suggestion.url,
+ impressionUrl: suggestion.impression_url,
+ clickUrl: suggestion.click_url,
+ blockId: suggestion.id,
+ });
+}
+
+function makeExpectedAddonResult({ suggestion, source, provider }) {
+ return makeAmoResult({
+ source,
+ provider,
+ title: suggestion.title,
+ description: suggestion.description,
+ url: suggestion.url,
+ originalUrl: suggestion.url,
+ icon: suggestion.icon,
+ });
+}
+
+function makeExpectedDefaultResult({ suggestion }) {
+ return {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ source: "merino",
+ provider: suggestion.provider,
+ telemetryType: suggestion.provider,
+ isSponsored: suggestion.is_sponsored,
+ title: suggestion.title,
+ url: suggestion.url,
+ displayUrl: suggestion.url.replace(/^https:\/\//, ""),
+ icon: suggestion.icon,
+ descriptionL10n: suggestion.is_sponsored
+ ? { id: "urlbar-result-action-sponsored" }
+ : undefined,
+ shouldShowUrl: true,
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ },
+ };
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js
new file mode 100644
index 0000000000..1b8da54920
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_topPicks.js
@@ -0,0 +1,192 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests top pick quick suggest results. "Top picks" refers to two different
+// concepts:
+//
+// (1) Any type of suggestion from Merino can have a boolean property called
+// `is_top_pick`. When true, Firefox should show the suggestion using the
+// "best match" UI treatment (labeled "top pick" in the UI) that makes a
+// result's row larger than usual and sets `suggestedIndex` to 1.
+// (2) There is a Merino provider called "top_picks" that returns a specific
+// type of suggestion called "navigational suggestions". These suggestions
+// also have `is_top_pick` set to true.
+//
+// This file tests aspects of both concepts.
+
+"use strict";
+
+const SUGGESTION_SEARCH_STRING = "example";
+const SUGGESTION_URL = "http://example.com/";
+const SUGGESTION_URL_WWW = "http://www.example.com/";
+const SUGGESTION_URL_DISPLAY = "http://example.com";
+
+const MERINO_SUGGESTIONS = [
+ {
+ is_top_pick: true,
+ provider: "top_picks",
+ url: SUGGESTION_URL,
+ title: "title",
+ icon: "icon",
+ is_sponsored: false,
+ score: 1,
+ },
+];
+
+add_setup(async function init() {
+ // Disable search suggestions so we don't hit the network.
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ merinoSuggestions: MERINO_SUGGESTIONS,
+ prefs: [["suggest.quicksuggest.nonsponsored", true]],
+ });
+});
+
+// When non-sponsored suggestions are disabled, navigational suggestions should
+// be disabled.
+add_task(async function nonsponsoredDisabled() {
+ // Disable sponsored suggestions. Navigational suggestions are non-sponsored,
+ // so doing this should not prevent them from being enabled.
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+
+ // First make sure the suggestion is added when non-sponsored suggestions are
+ // enabled.
+ await check_results({
+ context: createContext(SUGGESTION_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ isBestMatch: true,
+ suggestedIndex: 1,
+ }),
+ ],
+ });
+
+ // Now disable them.
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+ await check_results({
+ context: createContext(SUGGESTION_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", true);
+ UrlbarPrefs.clear("suggest.quicksuggest.sponsored");
+});
+
+// Test that bestMatch navigational suggestion results are not shown when there
+// is a heuristic result for the same domain.
+add_task(async function heuristicDeduplication() {
+ let expectedNavSuggestResult = makeExpectedResult({
+ isBestMatch: true,
+ suggestedIndex: 1,
+ dupedHeuristic: false,
+ });
+
+ let scenarios = [
+ [SUGGESTION_URL, false],
+ [SUGGESTION_URL_WWW, false],
+ ["http://exampledomain.com/", true],
+ ];
+
+ // Stub `UrlbarProviderQuickSuggest.startQuery()` so we can collect the
+ // results it adds for each query.
+ let addedResults = [];
+ let sandbox = sinon.createSandbox();
+ let startQueryStub = sandbox.stub(UrlbarProviderQuickSuggest, "startQuery");
+ startQueryStub.callsFake((queryContext, add) => {
+ let fakeAdd = (provider, result) => {
+ addedResults.push(result);
+ add(provider, result);
+ };
+ return startQueryStub.wrappedMethod.call(
+ UrlbarProviderQuickSuggest,
+ queryContext,
+ fakeAdd
+ );
+ });
+
+ for (let [url, expectBestMatch] of scenarios) {
+ await PlacesTestUtils.addVisits(url);
+
+ // Do a search and check the results.
+ let context = createContext(SUGGESTION_SEARCH_STRING, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderAutofill.name],
+ isPrivate: false,
+ });
+ const EXPECTED_AUTOFILL_RESULT = makeVisitResult(context, {
+ uri: url,
+ title: `test visit for ${url}`,
+ heuristic: true,
+ });
+ await check_results({
+ context,
+ matches: expectBestMatch
+ ? [EXPECTED_AUTOFILL_RESULT, expectedNavSuggestResult]
+ : [EXPECTED_AUTOFILL_RESULT],
+ });
+
+ // Regardless of whether it was shown, one result should have been added and
+ // its `payload.dupedHeuristic` should be set properly.
+ Assert.equal(
+ addedResults.length,
+ 1,
+ "The provider should have added one result"
+ );
+ Assert.equal(
+ !addedResults[0].payload.dupedHeuristic,
+ expectBestMatch,
+ "dupedHeuristic should be the opposite of expectBestMatch"
+ );
+ addedResults = [];
+
+ await PlacesUtils.history.clear();
+ }
+
+ sandbox.restore();
+});
+
+function makeExpectedResult({
+ isBestMatch,
+ suggestedIndex,
+ dupedHeuristic,
+ telemetryType = "top_picks",
+}) {
+ let result = {
+ isBestMatch,
+ suggestedIndex,
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ dupedHeuristic,
+ telemetryType,
+ title: "title",
+ url: SUGGESTION_URL,
+ displayUrl: SUGGESTION_URL_DISPLAY,
+ icon: "icon",
+ isSponsored: false,
+ shouldShowUrl: true,
+ source: "merino",
+ provider: telemetryType,
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ },
+ };
+ if (typeof dupedHeuristic == "boolean") {
+ result.payload.dupedHeuristic = dupedHeuristic;
+ }
+ return result;
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js
new file mode 100644
index 0000000000..aa9c700f1c
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_quicksuggest_yelp.js
@@ -0,0 +1,842 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests Yelp suggestions.
+
+"use strict";
+
+const { GEOLOCATION } = MerinoTestUtils;
+
+const REMOTE_SETTINGS_RECORDS = [
+ {
+ type: "yelp-suggestions",
+ attachment: {
+ subjects: ["ramen", "ab", "alongerkeyword"],
+ preModifiers: ["best"],
+ postModifiers: ["delivery"],
+ locationSigns: [
+ { keyword: "in", needLocation: true },
+ { keyword: "nearby", needLocation: false },
+ ],
+ yelpModifiers: [],
+ icon: "1234",
+ score: 0.5,
+ },
+ },
+];
+
+add_setup(async function () {
+ Services.prefs.setBoolPref("browser.search.suggest.enabled", false);
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: REMOTE_SETTINGS_RECORDS,
+ prefs: [
+ ["quicksuggest.rustEnabled", true],
+ ["suggest.quicksuggest.sponsored", true],
+ ["suggest.yelp", true],
+ ["yelp.featureGate", true],
+ ["yelp.minKeywordLength", 5],
+ ],
+ });
+
+ await MerinoTestUtils.initGeolocation();
+});
+
+add_task(async function basic() {
+ const TEST_DATA = [
+ {
+ description: "Basic",
+ query: "best ramen delivery in tokyo",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=best+ramen+delivery&find_loc=tokyo",
+ title: "best ramen delivery in tokyo",
+ },
+ },
+ {
+ description: "With upper case",
+ query: "BeSt RaMeN dElIvErY iN tOkYo",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=BeSt+RaMeN+dElIvErY&find_loc=tOkYo",
+ title: "BeSt RaMeN dElIvErY iN tOkYo",
+ },
+ },
+ {
+ description: "No specific location with location-sign",
+ query: "ramen in",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=ramen",
+ displayUrl:
+ "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa",
+ title: "ramen in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description: "No specific location with location-modifier",
+ query: "ramen nearby",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=ramen+nearby&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=ramen+nearby",
+ displayUrl:
+ "yelp.com/search?find_desc=ramen+nearby&find_loc=Yokohama,+Kanagawa",
+ title: "ramen nearby in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description: "Query too short, no subject exact match: ra",
+ query: "ra",
+ expected: null,
+ },
+ {
+ description: "Query too short, no subject not exact match: ram",
+ query: "ram",
+ expected: null,
+ },
+ {
+ description: "Query too short, no subject exact match: rame",
+ query: "rame",
+ expected: null,
+ },
+ {
+ description:
+ "Query length == minKeywordLength, subject exact match: ramen",
+ query: "ramen",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=ramen",
+ displayUrl:
+ "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa",
+ title: "ramen in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description: "Pre-modifier only",
+ query: "best",
+ expected: null,
+ },
+ {
+ description: "Pre-modifier only with trailing space",
+ query: "best ",
+ expected: null,
+ },
+ {
+ description: "Pre-modifier, subject too short",
+ query: "best r",
+ expected: null,
+ },
+ {
+ description: "Pre-modifier, query long enough, subject long enough",
+ query: "best ra",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=best+ramen&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=best+ramen",
+ displayUrl:
+ "yelp.com/search?find_desc=best+ramen&find_loc=Yokohama,+Kanagawa",
+ title: "best ramen in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description: "Subject exact match with length < minKeywordLength",
+ query: "ab",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=ab&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=ab",
+ displayUrl: "yelp.com/search?find_desc=ab&find_loc=Yokohama,+Kanagawa",
+ title: "ab in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description:
+ "Subject exact match with length < minKeywordLength, showLessFrequentlyCount non-zero",
+ query: "ab",
+ showLessFrequentlyCount: 1,
+ expected: null,
+ },
+ {
+ description:
+ "Subject exact match with length == minKeywordLength, showLessFrequentlyCount non-zero",
+ query: "ramen",
+ showLessFrequentlyCount: 1,
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=ramen",
+ displayUrl:
+ "yelp.com/search?find_desc=ramen&find_loc=Yokohama,+Kanagawa",
+ title: "ramen in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description: "Query too short: alon",
+ query: "alon",
+ expected: null,
+ },
+ {
+ description: "Query length == minKeywordLength, subject not exact match",
+ query: "along",
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword",
+ displayUrl:
+ "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa",
+ title: "alongerkeyword in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description:
+ "Query length == minKeywordLength, subject not exact match, showLessFrequentlyCount non-zero",
+ query: "along",
+ showLessFrequentlyCount: 1,
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword",
+ displayUrl:
+ "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa",
+ title: "alongerkeyword in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description:
+ "Query length == minKeywordLength + showLessFrequentlyCount, subject not exact match",
+ query: "alonge",
+ showLessFrequentlyCount: 1,
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword",
+ displayUrl:
+ "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa",
+ title: "alongerkeyword in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description:
+ "Query length < minKeywordLength + showLessFrequentlyCount, subject not exact match",
+ query: "alonge",
+ showLessFrequentlyCount: 2,
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword",
+ displayUrl:
+ "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa",
+ title: "alongerkeyword in Yokohama, Kanagawa",
+ },
+ },
+ {
+ description:
+ "Query length == minKeywordLength + showLessFrequentlyCount, subject not exact match",
+ query: "alonger",
+ showLessFrequentlyCount: 2,
+ expected: {
+ url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama%2C+Kanagawa",
+ originalUrl: "https://www.yelp.com/search?find_desc=alongerkeyword",
+ displayUrl:
+ "yelp.com/search?find_desc=alongerkeyword&find_loc=Yokohama,+Kanagawa",
+ title: "alongerkeyword in Yokohama, Kanagawa",
+ },
+ },
+ ];
+
+ for (let {
+ description,
+ query,
+ showLessFrequentlyCount,
+ expected,
+ } of TEST_DATA) {
+ info(
+ "Doing basic subtest: " +
+ JSON.stringify({
+ description,
+ query,
+ showLessFrequentlyCount,
+ expected,
+ })
+ );
+
+ if (typeof showLessFrequentlyCount == "number") {
+ UrlbarPrefs.set("yelp.showLessFrequentlyCount", showLessFrequentlyCount);
+ }
+
+ await check_results({
+ context: createContext(query, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: expected ? [makeExpectedResult(expected)] : [],
+ });
+
+ UrlbarPrefs.clear("yelp.showLessFrequentlyCount");
+ }
+});
+
+add_task(async function telemetryType() {
+ Assert.equal(
+ QuickSuggest.getFeature("YelpSuggestions").getSuggestionTelemetryType({}),
+ "yelp",
+ "Telemetry type should be 'yelp'"
+ );
+});
+
+// When sponsored suggestions are disabled, Yelp suggestions should be
+// disabled.
+add_task(async function sponsoredDisabled() {
+ UrlbarPrefs.set("suggest.quicksuggest.nonsponsored", false);
+
+ // First make sure the suggestion is added when non-sponsored
+ // suggestions are enabled, if the rust is enabled.
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ }),
+ ],
+ });
+
+ // Now disable the pref.
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", false);
+ Assert.ok(
+ !QuickSuggest.getFeature("YelpSuggestions").isEnabled,
+ "Yelp should be disabled"
+ );
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ UrlbarPrefs.set("suggest.quicksuggest.sponsored", true);
+ UrlbarPrefs.clear("suggest.quicksuggest.nonsponsored");
+ await QuickSuggestTestUtils.forceSync();
+
+ // Make sure Yelp is enabled again.
+ Assert.ok(
+ QuickSuggest.getFeature("YelpSuggestions").isEnabled,
+ "Yelp should be re-enabled"
+ );
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ }),
+ ],
+ });
+});
+
+// When Yelp-specific preferences are disabled, suggestions should not be
+// added.
+add_task(async function yelpSpecificPrefsDisabled() {
+ const prefs = ["suggest.yelp", "yelp.featureGate"];
+ for (const pref of prefs) {
+ // First make sure the suggestion is added, if the rust is enabled.
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ }),
+ ],
+ });
+
+ // Now disable the pref.
+ UrlbarPrefs.set(pref, false);
+ Assert.ok(
+ !QuickSuggest.getFeature("YelpSuggestions").isEnabled,
+ "Yelp should be disabled"
+ );
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Revert.
+ UrlbarPrefs.set(pref, true);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Make sure Yelp is enabled again.
+ Assert.ok(
+ QuickSuggest.getFeature("YelpSuggestions").isEnabled,
+ "Yelp should be re-enabled"
+ );
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ }),
+ ],
+ });
+ }
+});
+
+// Check wheather the Yelp suggestions will be shown by the setup of Nimbus
+// variable.
+add_task(async function featureGate() {
+ // Disable the fature gate.
+ UrlbarPrefs.set("yelp.featureGate", false);
+ await check_results({
+ context: createContext("ramem in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // Enable by Nimbus.
+ const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({
+ yelpFeatureGate: true,
+ });
+ await QuickSuggestTestUtils.forceSync();
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ }),
+ ],
+ });
+ await cleanUpNimbusEnable();
+
+ // Enable locally.
+ UrlbarPrefs.set("yelp.featureGate", true);
+ await QuickSuggestTestUtils.forceSync();
+
+ // Disable by Nimbus.
+ const cleanUpNimbusDisable = await UrlbarTestUtils.initNimbusFeature({
+ yelpFeatureGate: false,
+ });
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+ await cleanUpNimbusDisable();
+
+ // Revert.
+ UrlbarPrefs.set("yelp.featureGate", true);
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// Check wheather the Yelp suggestions will be shown as top_pick by the Nimbus
+// variable.
+add_task(async function yelpSuggestPriority() {
+ // Enable by Nimbus.
+ const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({
+ yelpSuggestPriority: true,
+ });
+ await QuickSuggestTestUtils.forceSync();
+
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ isTopPick: true,
+ }),
+ ],
+ });
+
+ await cleanUpNimbusEnable();
+ await QuickSuggestTestUtils.forceSync();
+
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ isTopPick: false,
+ }),
+ ],
+ });
+});
+
+// Tests the `yelpSuggestNonPriorityIndex` Nimbus variable, which controls the
+// group-relative suggestedIndex. The default Yelp suggestedIndex is 0, unlike
+// most other Suggest suggestion types, which use -1.
+add_task(async function nimbusSuggestedIndex() {
+ const cleanUpNimbusEnable = await UrlbarTestUtils.initNimbusFeature({
+ yelpSuggestNonPriorityIndex: -1,
+ });
+ await QuickSuggestTestUtils.forceSync();
+
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ isTopPick: false,
+ suggestedIndex: -1,
+ }),
+ ],
+ });
+
+ await cleanUpNimbusEnable();
+ await QuickSuggestTestUtils.forceSync();
+
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ isTopPick: false,
+ suggestedIndex: 0,
+ }),
+ ],
+ });
+});
+
+// Tests the "Not relevant" command: a dismissed suggestion shouldn't be added.
+add_task(async function notRelevant() {
+ let result = makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ });
+
+ info("Triggering the 'Not relevant' command");
+ QuickSuggest.getFeature("YelpSuggestions").handleCommand(
+ {
+ controller: { removeResult() {} },
+ },
+ result,
+ "not_relevant"
+ );
+ await QuickSuggest.blockedSuggestions._test_readyPromise;
+
+ Assert.ok(
+ await QuickSuggest.blockedSuggestions.has(result.payload.originalUrl),
+ "The result's URL should be blocked"
+ );
+
+ info("Doing search for blocked suggestion");
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ info("Doing search for a suggestion that wasn't blocked");
+ await check_results({
+ context: createContext("alongerkeyword in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [
+ makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=alongerkeyword&find_loc=tokyo",
+ title: "alongerkeyword in tokyo",
+ }),
+ ],
+ });
+
+ info("Clearing blocked suggestions");
+ await QuickSuggest.blockedSuggestions.clear();
+
+ info("Doing search for unblocked suggestion");
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [result],
+ });
+});
+
+// Tests the "Not interested" command: all Yelp suggestions should be disabled
+// and not added anymore.
+add_task(async function notInterested() {
+ let result = makeExpectedResult({
+ url: "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ title: "ramen in tokyo",
+ });
+
+ info("Triggering the 'Not interested' command");
+ QuickSuggest.getFeature("YelpSuggestions").handleCommand(
+ {
+ controller: { removeResult() {} },
+ },
+ result,
+ "not_interested"
+ );
+
+ Assert.ok(
+ !UrlbarPrefs.get("suggest.yelp"),
+ "Yelp suggestions should be disabled"
+ );
+
+ info("Doing search for the suggestion the command was used on");
+ await check_results({
+ context: createContext("ramen in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ info("Doing search for another Yelp suggestion");
+ await check_results({
+ context: createContext("alongerkeyword in tokyo", {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ UrlbarPrefs.clear("suggest.yelp");
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// Tests the "show less frequently" behavior.
+add_task(async function showLessFrequently() {
+ UrlbarPrefs.set("yelp.showLessFrequentlyCount", 0);
+ UrlbarPrefs.set("yelp.minKeywordLength", 0);
+ let cleanUpNimbus = await UrlbarTestUtils.initNimbusFeature({
+ yelpMinKeywordLength: 0,
+ yelpShowLessFrequentlyCap: 3,
+ });
+
+ let location = `${GEOLOCATION.city}, ${GEOLOCATION.region}`;
+
+ let originalUrl = new URL("https://www.yelp.com/search");
+ originalUrl.searchParams.set("find_desc", "best ramen");
+
+ let url = new URL(originalUrl);
+ url.searchParams.set("find_loc", location);
+
+ let result = makeExpectedResult({
+ url: url.toString(),
+ originalUrl: originalUrl.toString(),
+ title: `best ramen in ${location}`,
+ });
+
+ const testData = [
+ {
+ input: "best ra",
+ before: {
+ canShowLessFrequently: true,
+ showLessFrequentlyCount: 0,
+ minKeywordLength: 0,
+ },
+ after: {
+ canShowLessFrequently: true,
+ showLessFrequentlyCount: 1,
+ minKeywordLength: 8,
+ },
+ },
+ {
+ input: "best ram",
+ before: {
+ canShowLessFrequently: true,
+ showLessFrequentlyCount: 1,
+ minKeywordLength: 8,
+ },
+ after: {
+ canShowLessFrequently: true,
+ showLessFrequentlyCount: 2,
+ minKeywordLength: 9,
+ },
+ },
+ {
+ input: "best rame",
+ before: {
+ canShowLessFrequently: true,
+ showLessFrequentlyCount: 2,
+ minKeywordLength: 9,
+ },
+ after: {
+ canShowLessFrequently: false,
+ showLessFrequentlyCount: 3,
+ minKeywordLength: 10,
+ },
+ },
+ {
+ input: "best ramen",
+ before: {
+ canShowLessFrequently: false,
+ showLessFrequentlyCount: 3,
+ minKeywordLength: 10,
+ },
+ after: {
+ canShowLessFrequently: false,
+ showLessFrequentlyCount: 3,
+ minKeywordLength: 11,
+ },
+ },
+ ];
+
+ for (let { input, before, after } of testData) {
+ let feature = QuickSuggest.getFeature("YelpSuggestions");
+
+ await check_results({
+ context: createContext(input, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [result],
+ });
+
+ Assert.equal(
+ UrlbarPrefs.get("yelp.minKeywordLength"),
+ before.minKeywordLength
+ );
+ Assert.equal(feature.canShowLessFrequently, before.canShowLessFrequently);
+ Assert.equal(
+ feature.showLessFrequentlyCount,
+ before.showLessFrequentlyCount
+ );
+
+ feature.handleCommand(
+ {
+ acknowledgeFeedback: () => {},
+ invalidateResultMenuCommands: () => {},
+ },
+ result,
+ "show_less_frequently",
+ input
+ );
+
+ Assert.equal(
+ UrlbarPrefs.get("yelp.minKeywordLength"),
+ after.minKeywordLength
+ );
+ Assert.equal(feature.canShowLessFrequently, after.canShowLessFrequently);
+ Assert.equal(
+ feature.showLessFrequentlyCount,
+ after.showLessFrequentlyCount
+ );
+
+ await check_results({
+ context: createContext(input, {
+ providers: [UrlbarProviderQuickSuggest.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+ }
+
+ await cleanUpNimbus();
+ UrlbarPrefs.clear("yelp.showLessFrequentlyCount");
+ UrlbarPrefs.clear("yelp.minKeywordLength");
+});
+
+// The `Yelp` Rust provider should be passed to the Rust component when
+// querying depending on whether Yelp suggestions are enabled.
+add_task(async function rustProviders() {
+ await doRustProvidersTests({
+ searchString: "ramen in tokyo",
+ tests: [
+ {
+ prefs: {
+ "suggest.yelp": true,
+ },
+ expectedUrls: [
+ "https://www.yelp.com/search?find_desc=ramen&find_loc=tokyo",
+ ],
+ },
+ {
+ prefs: {
+ "suggest.yelp": false,
+ },
+ expectedUrls: [],
+ },
+ ],
+ });
+
+ UrlbarPrefs.clear("suggest.yelp");
+ await QuickSuggestTestUtils.forceSync();
+});
+
+function makeExpectedResult({
+ url,
+ title,
+ isTopPick = false,
+ // The default Yelp suggestedIndex is 0, unlike most other Suggest suggestion
+ // types, which use -1.
+ suggestedIndex = 0,
+ isSuggestedIndexRelativeToGroup = true,
+ originalUrl = undefined,
+ displayUrl = undefined,
+}) {
+ const utmParameters = "&utm_medium=partner&utm_source=mozilla";
+
+ originalUrl ??= url;
+
+ displayUrl =
+ (displayUrl ??
+ url
+ .replace(/^https:\/\/www[.]/, "")
+ .replace("%20", " ")
+ .replace("%2C", ",")) + utmParameters;
+
+ url += utmParameters;
+
+ if (isTopPick) {
+ suggestedIndex = 1;
+ isSuggestedIndexRelativeToGroup = false;
+ }
+
+ return {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ isBestMatch: !!isTopPick,
+ suggestedIndex,
+ isSuggestedIndexRelativeToGroup,
+ heuristic: false,
+ payload: {
+ source: "rust",
+ provider: "Yelp",
+ telemetryType: "yelp",
+ shouldShowUrl: true,
+ bottomTextL10n: { id: "firefox-suggest-yelp-bottom-text" },
+ url,
+ originalUrl,
+ title,
+ displayUrl,
+ icon: null,
+ },
+ };
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js b/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js
new file mode 100644
index 0000000000..e6ec61bcd4
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_rust_ingest.js
@@ -0,0 +1,244 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests ingest in the Rust backend.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+// These consts are copied from the update timer manager test. See
+// `initUpdateTimerManager()`.
+const PREF_APP_UPDATE_TIMERMINIMUMDELAY = "app.update.timerMinimumDelay";
+const PREF_APP_UPDATE_TIMERFIRSTINTERVAL = "app.update.timerFirstInterval";
+const MAIN_TIMER_INTERVAL = 1000; // milliseconds
+const CATEGORY_UPDATE_TIMER = "update-timer";
+
+const REMOTE_SETTINGS_SUGGESTION = {
+ id: 1,
+ url: "http://example.com/amp",
+ title: "AMP Suggestion",
+ keywords: ["amp"],
+ click_url: "http://example.com/amp-click",
+ impression_url: "http://example.com/amp-impression",
+ advertiser: "Amp",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+};
+
+add_setup(async function () {
+ initUpdateTimerManager();
+
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "data",
+ attachment: [REMOTE_SETTINGS_SUGGESTION],
+ },
+ ],
+ prefs: [
+ ["suggest.quicksuggest.sponsored", true],
+ ["quicksuggest.rustEnabled", false],
+ ],
+ });
+});
+
+// IMPORTANT: This task must run first!
+//
+// This simulates the first time the Rust backend is enabled in a profile. The
+// backend should perform ingestion immediately.
+add_task(async function firstRun() {
+ Assert.ok(
+ !UrlbarPrefs.get("quicksuggest.rustEnabled"),
+ "rustEnabled pref is initially false (this task must run first!)"
+ );
+ Assert.strictEqual(
+ QuickSuggest.rustBackend.isEnabled,
+ false,
+ "Rust backend is initially disabled (this task must run first!)"
+ );
+ Assert.ok(
+ !QuickSuggest.rustBackend.ingestPromise,
+ "No ingest has been performed yet (this task must run first!)"
+ );
+
+ info("Enabling the Rust backend");
+ UrlbarPrefs.set("quicksuggest.rustEnabled", true);
+ Assert.ok(QuickSuggest.rustBackend.isEnabled, "Rust backend is now enabled");
+
+ // An ingest should start.
+ let { ingestPromise } = await waitForIngestStart(null);
+
+ info("Awaiting ingest promise");
+ await ingestPromise;
+ info("Done awaiting ingest promise");
+
+ await checkSuggestions();
+
+ // Disable and re-enable the backend. No new ingestion should start
+ // immediately since this isn't the first time the backend has been enabled.
+ UrlbarPrefs.set("quicksuggest.rustEnabled", false);
+ UrlbarPrefs.set("quicksuggest.rustEnabled", true);
+ await assertNoNewIngestStarted(ingestPromise);
+
+ await checkSuggestions();
+
+ UrlbarPrefs.set("quicksuggest.rustEnabled", false);
+});
+
+// Ingestion should be performed according to the defined interval.
+add_task(async function interval() {
+ let { ingestPromise } = QuickSuggest.rustBackend;
+ Assert.ok(
+ ingestPromise,
+ "Sanity check: An ingest has already been performed"
+ );
+ Assert.ok(
+ !UrlbarPrefs.get("quicksuggest.rustEnabled"),
+ "Sanity check: Rust backend is initially disabled"
+ );
+
+ // Set a small interval and enable the backend. No new ingestion should start
+ // immediately since this isn't the first time the backend has been enabled.
+ let intervalSecs = 1;
+ UrlbarPrefs.set("quicksuggest.rustIngestIntervalSeconds", intervalSecs);
+ UrlbarPrefs.set("quicksuggest.rustEnabled", true);
+ await assertNoNewIngestStarted(ingestPromise);
+
+ // Wait for a few ingests to happen.
+ for (let i = 0; i < 3; i++) {
+ info("Preparing for ingest at index " + i);
+
+ // Set a new suggestion so we can make sure ingest really happened.
+ let suggestion = {
+ ...REMOTE_SETTINGS_SUGGESTION,
+ url: REMOTE_SETTINGS_SUGGESTION.url + "/" + i,
+ };
+ await QuickSuggestTestUtils.setRemoteSettingsRecords(
+ [
+ {
+ type: "data",
+ attachment: [suggestion],
+ },
+ ],
+ // Don't force sync since the whole point here is to make sure the backend
+ // ingests on its own!
+ { forceSync: false }
+ );
+
+ // Wait for ingest to start and finish.
+ info("Waiting for ingest to start at index " + i);
+ ({ ingestPromise } = await waitForIngestStart(ingestPromise));
+ info("Waiting for ingest to finish at index " + i);
+ await ingestPromise;
+ await checkSuggestions([suggestion]);
+ }
+
+ // In the loop above, there was one additional async call after awaiting the
+ // ingest promise, to `checkSuggestions()`. It's possible, though unlikely,
+ // that call took so long that another ingest has started. To be sure, wait
+ // for one final ingest to start before continuing.
+ ({ ingestPromise } = await waitForIngestStart(ingestPromise));
+
+ // Now immediately disable the backend. New ingests should not start, but the
+ // final one will still be ongoing.
+ info("Disabling the backend");
+ UrlbarPrefs.set("quicksuggest.rustEnabled", false);
+
+ info("Awaiting final ingest promise");
+ await ingestPromise;
+
+ // Wait a few seconds.
+ let waitSecs = 3 * intervalSecs;
+ info(`Waiting ${waitSecs}s...`);
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 1000 * waitSecs));
+
+ // No new ingests should have started.
+ Assert.equal(
+ QuickSuggest.rustBackend.ingestPromise,
+ ingestPromise,
+ "No new ingest started after disabling the backend"
+ );
+
+ UrlbarPrefs.clear("quicksuggest.rustIngestIntervalSeconds");
+});
+
+async function waitForIngestStart(oldIngestPromise) {
+ let newIngestPromise;
+ await TestUtils.waitForCondition(() => {
+ let { ingestPromise } = QuickSuggest.rustBackend;
+ if (
+ (oldIngestPromise && ingestPromise != oldIngestPromise) ||
+ (!oldIngestPromise && ingestPromise)
+ ) {
+ newIngestPromise = ingestPromise;
+ return true;
+ }
+ return false;
+ }, "Waiting for a new ingest to start");
+
+ Assert.equal(
+ QuickSuggest.rustBackend.ingestPromise,
+ newIngestPromise,
+ "Sanity check: ingestPromise hasn't changed since waitForCondition returned"
+ );
+
+ // A bare promise can't be returned because it will cause the awaiting caller
+ // to await that promise! We're simply trying to return the promise, which the
+ // caller can later await.
+ return { ingestPromise: newIngestPromise };
+}
+
+async function assertNoNewIngestStarted(oldIngestPromise) {
+ for (let i = 0; i < 3; i++) {
+ await TestUtils.waitForTick();
+ }
+ Assert.equal(
+ QuickSuggest.rustBackend.ingestPromise,
+ oldIngestPromise,
+ "No new ingest started"
+ );
+}
+
+async function checkSuggestions(expected = [REMOTE_SETTINGS_SUGGESTION]) {
+ let actual = await QuickSuggest.rustBackend.query("amp");
+ Assert.deepEqual(
+ actual.map(s => s.url),
+ expected.map(s => s.url),
+ "Backend should be serving the expected suggestions"
+ );
+}
+
+/**
+ * Sets up the update timer manager for testing: makes it fire more often,
+ * removes all existing timers, and initializes it for testing. The body of this
+ * function is copied from:
+ * https://searchfox.org/mozilla-central/source/toolkit/components/timermanager/tests/unit/consumerNotifications.js
+ */
+function initUpdateTimerManager() {
+ // Set the timer to fire every second
+ Services.prefs.setIntPref(
+ PREF_APP_UPDATE_TIMERMINIMUMDELAY,
+ MAIN_TIMER_INTERVAL / 1000
+ );
+ Services.prefs.setIntPref(
+ PREF_APP_UPDATE_TIMERFIRSTINTERVAL,
+ MAIN_TIMER_INTERVAL
+ );
+
+ // Remove existing update timers to prevent them from being notified
+ for (let { data: entry } of Services.catMan.enumerateCategory(
+ CATEGORY_UPDATE_TIMER
+ )) {
+ Services.catMan.deleteCategoryEntry(CATEGORY_UPDATE_TIMER, entry, false);
+ }
+
+ Cc["@mozilla.org/updates/timer-manager;1"]
+ .getService(Ci.nsIUpdateTimerManager)
+ .QueryInterface(Ci.nsIObserver)
+ .observe(null, "utm-test-init", "");
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js
new file mode 100644
index 0000000000..f50fe32dd3
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_suggestionsMap.js
@@ -0,0 +1,293 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests `SuggestionsMap`.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
+ SuggestionsMap: "resource:///modules/urlbar/private/SuggestBackendJs.sys.mjs",
+});
+
+// This overrides `SuggestionsMap.chunkSize`. Testing the actual value can make
+// the test run too long. This is OK because the correctness of the chunking
+// behavior doesn't depend on the chunk size.
+const TEST_CHUNK_SIZE = 100;
+
+add_setup(async () => {
+ // Sanity check the actual `chunkSize` value.
+ Assert.equal(
+ typeof SuggestionsMap.chunkSize,
+ "number",
+ "Sanity check: chunkSize is a number"
+ );
+ Assert.greater(SuggestionsMap.chunkSize, 0, "Sanity check: chunkSize > 0");
+
+ // Set our test value.
+ SuggestionsMap.chunkSize = TEST_CHUNK_SIZE;
+});
+
+// Tests many suggestions with one keyword each.
+add_task(async function chunking_singleKeyword() {
+ let suggestionCounts = [
+ 1 * SuggestionsMap.chunkSize - 1,
+ 1 * SuggestionsMap.chunkSize,
+ 1 * SuggestionsMap.chunkSize + 1,
+ 2 * SuggestionsMap.chunkSize - 1,
+ 2 * SuggestionsMap.chunkSize,
+ 2 * SuggestionsMap.chunkSize + 1,
+ 3 * SuggestionsMap.chunkSize - 1,
+ 3 * SuggestionsMap.chunkSize,
+ 3 * SuggestionsMap.chunkSize + 1,
+ ];
+ for (let count of suggestionCounts) {
+ await doChunkingTest(count, 1);
+ }
+});
+
+// Tests a small number of suggestions with many keywords each.
+add_task(async function chunking_manyKeywords() {
+ let keywordCounts = [
+ 1 * SuggestionsMap.chunkSize - 1,
+ 1 * SuggestionsMap.chunkSize,
+ 1 * SuggestionsMap.chunkSize + 1,
+ 2 * SuggestionsMap.chunkSize - 1,
+ 2 * SuggestionsMap.chunkSize,
+ 2 * SuggestionsMap.chunkSize + 1,
+ 3 * SuggestionsMap.chunkSize - 1,
+ 3 * SuggestionsMap.chunkSize,
+ 3 * SuggestionsMap.chunkSize + 1,
+ ];
+ for (let suggestionCount = 1; suggestionCount <= 3; suggestionCount++) {
+ for (let keywordCount of keywordCounts) {
+ await doChunkingTest(suggestionCount, keywordCount);
+ }
+ }
+});
+
+async function doChunkingTest(suggestionCount, keywordCountPerSuggestion) {
+ info(
+ "Running chunking test: " +
+ JSON.stringify({ suggestionCount, keywordCountPerSuggestion })
+ );
+
+ // Create `suggestionCount` suggestions, each with `keywordCountPerSuggestion`
+ // keywords.
+ let suggestions = [];
+ for (let i = 0; i < suggestionCount; i++) {
+ let keywords = [];
+ for (let k = 0; k < keywordCountPerSuggestion; k++) {
+ keywords.push(`keyword-${i}-${k}`);
+ }
+ suggestions.push({
+ keywords,
+ id: i,
+ url: "http://example.com/" + i,
+ title: "Suggestion " + i,
+ click_url: "http://example.com/click",
+ impression_url: "http://example.com/impression",
+ advertiser: "TestAdvertiser",
+ iab_category: "22 - Shopping",
+ });
+ }
+
+ // Add the suggestions.
+ let map = new SuggestionsMap();
+ await map.add(suggestions);
+
+ // Make sure all keyword-suggestion pairs have been added.
+ for (let i = 0; i < suggestionCount; i++) {
+ for (let k = 0; k < keywordCountPerSuggestion; k++) {
+ let keyword = `keyword-${i}-${k}`;
+
+ // Check the map. Logging all assertions takes a ton of time and makes the
+ // test run much longer than it otherwise would, especially if `chunkSize`
+ // is large, so only log failing assertions.
+ let actualSuggestions = map.get(keyword);
+ if (!ObjectUtils.deepEqual(actualSuggestions, [suggestions[i]])) {
+ Assert.deepEqual(
+ actualSuggestions,
+ [suggestions[i]],
+ `Suggestion ${i} is present for keyword ${keyword}`
+ );
+ }
+ }
+ }
+}
+
+add_task(async function duplicateKeywords() {
+ let suggestions = [
+ {
+ title: "suggestion 0",
+ keywords: ["a", "a", "a", "b", "b", "c"],
+ },
+ {
+ title: "suggestion 1",
+ keywords: ["b", "c", "d"],
+ },
+ {
+ title: "suggestion 2",
+ keywords: ["c", "d", "e"],
+ },
+ {
+ title: "suggestion 3",
+ keywords: ["f", "f"],
+ },
+ ];
+
+ let expectedIndexesByKeyword = {
+ a: [0],
+ b: [0, 1],
+ c: [0, 1, 2],
+ d: [1, 2],
+ e: [2],
+ f: [3],
+ };
+
+ let map = new SuggestionsMap();
+ await map.add(suggestions);
+
+ for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) {
+ Assert.deepEqual(
+ map.get(keyword),
+ indexes.map(i => suggestions[i]),
+ "get() with keyword: " + keyword
+ );
+ }
+});
+
+add_task(async function mapKeywords() {
+ let suggestions = [
+ {
+ title: "suggestion 0",
+ keywords: ["a", "a", "a", "b", "b", "c"],
+ },
+ {
+ title: "suggestion 1",
+ keywords: ["b", "c", "d"],
+ },
+ {
+ title: "suggestion 2",
+ keywords: ["c", "d", "e"],
+ },
+ {
+ title: "suggestion 3",
+ keywords: ["f", "f"],
+ },
+ ];
+
+ let expectedIndexesByKeyword = {
+ a: [],
+ b: [],
+ c: [],
+ d: [],
+ e: [],
+ f: [],
+ ax: [0],
+ bx: [0, 1],
+ cx: [0, 1, 2],
+ dx: [1, 2],
+ ex: [2],
+ fx: [3],
+ fy: [3],
+ fz: [3],
+ };
+
+ let map = new SuggestionsMap();
+ await map.add(suggestions, {
+ mapKeyword: keyword => {
+ if (keyword == "f") {
+ return [keyword + "x", keyword + "y", keyword + "z"];
+ }
+ return [keyword + "x"];
+ },
+ });
+
+ for (let [keyword, indexes] of Object.entries(expectedIndexesByKeyword)) {
+ Assert.deepEqual(
+ map.get(keyword),
+ indexes.map(i => suggestions[i]),
+ "get() with keyword: " + keyword
+ );
+ }
+});
+
+// Tests `keywordsProperty`.
+add_task(async function keywordsProperty() {
+ let suggestion = {
+ title: "suggestion",
+ keywords: ["should be ignored"],
+ foo: ["hello"],
+ };
+
+ let map = new SuggestionsMap();
+ await map.add([suggestion], {
+ keywordsProperty: "foo",
+ });
+
+ Assert.deepEqual(
+ map.get("hello"),
+ [suggestion],
+ "Keyword in `foo` should match"
+ );
+ Assert.deepEqual(
+ map.get("should be ignored"),
+ [],
+ "Keyword in `keywords` should not match"
+ );
+});
+
+// Tests `MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD`.
+add_task(async function prefixesStartingAtFirstWord() {
+ let suggestion = {
+ title: "suggestion",
+ keywords: ["one two three", "four five six"],
+ };
+
+ // keyword passed to `get()` -> should match
+ let tests = {
+ o: false,
+ on: false,
+ one: true,
+ "one ": true,
+ "one t": true,
+ "one tw": true,
+ "one two": true,
+ "one two ": true,
+ "one two t": true,
+ "one two th": true,
+ "one two thr": true,
+ "one two thre": true,
+ "one two three": true,
+ "one two three ": false,
+ f: false,
+ fo: false,
+ fou: false,
+ four: true,
+ "four ": true,
+ "four f": true,
+ "four fi": true,
+ "four fiv": true,
+ "four five": true,
+ "four five ": true,
+ "four five s": true,
+ "four five si": true,
+ "four five six": true,
+ "four five six ": false,
+ };
+
+ let map = new SuggestionsMap();
+ await map.add([suggestion], {
+ mapKeyword: SuggestionsMap.MAP_KEYWORD_PREFIXES_STARTING_AT_FIRST_WORD,
+ });
+
+ for (let [keyword, shouldMatch] of Object.entries(tests)) {
+ Assert.deepEqual(
+ map.get(keyword),
+ shouldMatch ? [suggestion] : [],
+ "get() with keyword: " + keyword
+ );
+ }
+});
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js
new file mode 100644
index 0000000000..28801904a1
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather.js
@@ -0,0 +1,1402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests the quick suggest weather feature.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
+ UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs",
+});
+
+const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS";
+const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER";
+
+const { WEATHER_RS_DATA, WEATHER_SUGGESTION } = MerinoTestUtils;
+
+add_setup(async () => {
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ prefs: [["suggest.quicksuggest.nonsponsored", true]],
+ remoteSettingsRecords: [
+ {
+ type: "weather",
+ weather: WEATHER_RS_DATA,
+ },
+ ],
+ });
+
+ await MerinoTestUtils.initWeather();
+
+ // Give this a small value so it doesn't delay the test too long. Choose a
+ // value that's unlikely to be used anywhere else in the test so that when
+ // `lastFetchTimeMs` is expected to be `fetchDelayAfterComingOnlineMs`, we can
+ // be sure the value actually came from `fetchDelayAfterComingOnlineMs`.
+ QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs = 53;
+});
+
+// The feature should be properly uninitialized when it's disabled and then
+// re-initialized when it's re-enabled. This task disables the feature using the
+// feature gate pref.
+add_tasks_with_rust(async function disableAndEnable_featureGate() {
+ await doBasicDisableAndEnableTest("weather.featureGate");
+});
+
+// The feature should be properly uninitialized when it's disabled and then
+// re-initialized when it's re-enabled. This task disables the feature using the
+// suggest pref.
+add_tasks_with_rust(async function disableAndEnable_suggestPref() {
+ await doBasicDisableAndEnableTest("suggest.weather");
+});
+
+async function doBasicDisableAndEnableTest(pref) {
+ // Sanity check initial state.
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ // Disable the feature. It should be immediately uninitialized.
+ UrlbarPrefs.set(pref, false);
+ assertDisabled({
+ message: "After disabling",
+ pendingFetchCount: 0,
+ });
+
+ // No suggestion should be returned for a search.
+ let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ let histograms = MerinoTestUtils.getAndClearHistograms({
+ extraLatency: HISTOGRAM_LATENCY,
+ extraResponse: HISTOGRAM_RESPONSE,
+ });
+
+ // Re-enable the feature. It should be immediately initialized and a fetch
+ // should start.
+ info("Re-enable the feature");
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ UrlbarPrefs.set(pref, true);
+ assertEnabled({
+ message: "Immediately after re-enabling",
+ hasSuggestion: false,
+ pendingFetchCount: 1,
+ });
+
+ await fetchPromise;
+ assertEnabled({
+ message: "After awaiting fetch",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ Assert.equal(
+ QuickSuggest.weather._test_merino.lastFetchStatus,
+ "success",
+ "The request successfully finished"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "success",
+ latencyRecorded: true,
+ client: QuickSuggest.weather._test_merino,
+ });
+
+ // Wait for keywords to be re-synced from remote settings.
+ await QuickSuggestTestUtils.forceSync();
+
+ // The suggestion should be returned for a search.
+ context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [makeWeatherResult()],
+ });
+}
+
+// This task is only appropriate for the JS backend, not Rust, since fetching is
+// always active with Rust.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function keywordsNotDefined() {
+ // Sanity check initial state.
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ // Set RS data without any keywords. Fetching should immediately stop.
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: {},
+ },
+ ]);
+ assertDisabled({
+ message: "After setting RS data without keywords",
+ pendingFetchCount: 0,
+ });
+
+ // No suggestion should be returned for a search.
+ let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ // Set keywords. Fetching should immediately start.
+ info("Setting keywords");
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ]);
+ assertEnabled({
+ message: "Immediately after setting keywords",
+ hasSuggestion: false,
+ pendingFetchCount: 1,
+ });
+
+ await fetchPromise;
+ assertEnabled({
+ message: "After awaiting fetch",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ Assert.equal(
+ QuickSuggest.weather._test_merino.lastFetchStatus,
+ "success",
+ "The request successfully finished"
+ );
+
+ // The suggestion should be returned for a search.
+ context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [makeWeatherResult()],
+ });
+ }
+);
+
+// Disables and re-enables the feature without waiting for any intermediate
+// fetches to complete, using the following steps:
+//
+// 1. Disable
+// 2. Enable
+// 3. Disable again
+//
+// At this point, the fetch from step 2 will remain ongoing but once it finishes
+// it should be discarded since the feature is disabled.
+add_tasks_with_rust(async function disableAndEnable_immediate1() {
+ // Sanity check initial state.
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ // Disable the feature. It should be immediately uninitialized.
+ UrlbarPrefs.set("weather.featureGate", false);
+ assertDisabled({
+ message: "After disabling",
+ pendingFetchCount: 0,
+ });
+
+ // Re-enable the feature. It should be immediately initialized and a fetch
+ // should start.
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ UrlbarPrefs.set("weather.featureGate", true);
+ assertEnabled({
+ message: "Immediately after re-enabling",
+ hasSuggestion: false,
+ pendingFetchCount: 1,
+ });
+
+ // Disable it again. The fetch will remain ongoing since pending fetches
+ // aren't stopped when the feature is disabled.
+ UrlbarPrefs.set("weather.featureGate", false);
+ assertDisabled({
+ message: "After disabling again",
+ pendingFetchCount: 1,
+ });
+
+ // Wait for the fetch to finish.
+ await fetchPromise;
+
+ // The fetched suggestion should be discarded and the feature should remain
+ // uninitialized.
+ assertDisabled({
+ message: "After awaiting fetch",
+ pendingFetchCount: 0,
+ });
+
+ // Clean up by re-enabling the feature for the remaining tasks.
+ fetchPromise = QuickSuggest.weather.waitForFetches();
+ UrlbarPrefs.set("weather.featureGate", true);
+ await fetchPromise;
+
+ // Wait for keywords to be re-synced from remote settings.
+ await QuickSuggestTestUtils.forceSync();
+
+ assertEnabled({
+ message: "On cleanup",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+});
+
+// Disables and re-enables the feature without waiting for any intermediate
+// fetches to complete, using the following steps:
+//
+// 1. Disable
+// 2. Enable
+// 3. Disable again
+// 4. Enable again
+//
+// At this point, the fetches from steps 2 and 4 will remain ongoing. The fetch
+// from step 2 should be discarded.
+add_tasks_with_rust(async function disableAndEnable_immediate2() {
+ // Sanity check initial state.
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ // Disable the feature. It should be immediately uninitialized.
+ UrlbarPrefs.set("weather.featureGate", false);
+ assertDisabled({
+ message: "After disabling",
+ pendingFetchCount: 0,
+ });
+
+ // Re-enable the feature. It should be immediately initialized and a fetch
+ // should start.
+ UrlbarPrefs.set("weather.featureGate", true);
+ assertEnabled({
+ message: "Immediately after re-enabling",
+ hasSuggestion: false,
+ pendingFetchCount: 1,
+ });
+
+ // Disable it again. The fetch will remain ongoing since pending fetches
+ // aren't stopped when the feature is disabled.
+ UrlbarPrefs.set("weather.featureGate", false);
+ assertDisabled({
+ message: "After disabling again",
+ pendingFetchCount: 1,
+ });
+
+ // Re-enable it. A new fetch should start, so now there will be two pending
+ // fetches.
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ UrlbarPrefs.set("weather.featureGate", true);
+ assertEnabled({
+ message: "Immediately after re-enabling again",
+ hasSuggestion: false,
+ pendingFetchCount: 2,
+ });
+
+ // Wait for both fetches to finish.
+ await fetchPromise;
+ assertEnabled({
+ message: "Immediately after re-enabling again",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ // Wait for keywords to be re-synced from remote settings.
+ await QuickSuggestTestUtils.forceSync();
+});
+
+// A fetch that doesn't return a suggestion should cause the last-fetched
+// suggestion to be discarded.
+add_tasks_with_rust(async function noSuggestion() {
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ let histograms = MerinoTestUtils.getAndClearHistograms({
+ extraLatency: HISTOGRAM_LATENCY,
+ extraResponse: HISTOGRAM_RESPONSE,
+ });
+
+ let { suggestions } = MerinoTestUtils.server.response.body;
+ MerinoTestUtils.server.response.body.suggestions = [];
+
+ await QuickSuggest.weather._test_fetch();
+
+ assertEnabled({
+ message: "After fetch",
+ hasSuggestion: false,
+ pendingFetchCount: 0,
+ });
+ Assert.equal(
+ QuickSuggest.weather._test_merino.lastFetchStatus,
+ "no_suggestion",
+ "The request successfully finished without suggestions"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "no_suggestion",
+ latencyRecorded: true,
+ client: QuickSuggest.weather._test_merino,
+ });
+
+ let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ MerinoTestUtils.server.response.body.suggestions = suggestions;
+
+ // Clean up by forcing another fetch so the suggestion is non-null for the
+ // remaining tasks.
+ await QuickSuggest.weather._test_fetch();
+ assertEnabled({
+ message: "On cleanup",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+});
+
+// A network error should cause the last-fetched suggestion to be discarded.
+add_tasks_with_rust(async function networkError() {
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ let histograms = MerinoTestUtils.getAndClearHistograms({
+ extraLatency: HISTOGRAM_LATENCY,
+ extraResponse: HISTOGRAM_RESPONSE,
+ });
+
+ // Set the weather fetch timeout high enough that the network error exception
+ // will happen first. See `MerinoTestUtils.withNetworkError()`.
+ QuickSuggest.weather._test_setTimeoutMs(10000);
+
+ await MerinoTestUtils.server.withNetworkError(async () => {
+ await QuickSuggest.weather._test_fetch();
+ });
+
+ QuickSuggest.weather._test_setTimeoutMs(-1);
+
+ assertEnabled({
+ message: "After fetch",
+ hasSuggestion: false,
+ pendingFetchCount: 0,
+ });
+ Assert.equal(
+ QuickSuggest.weather._test_merino.lastFetchStatus,
+ "network_error",
+ "The request failed with a network error"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "network_error",
+ latencyRecorded: false,
+ client: QuickSuggest.weather._test_merino,
+ });
+
+ let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ // Clean up by forcing another fetch so the suggestion is non-null for the
+ // remaining tasks.
+ await QuickSuggest.weather._test_fetch();
+ assertEnabled({
+ message: "On cleanup",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+});
+
+// An HTTP error should cause the last-fetched suggestion to be discarded.
+add_tasks_with_rust(async function httpError() {
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ let histograms = MerinoTestUtils.getAndClearHistograms({
+ extraLatency: HISTOGRAM_LATENCY,
+ extraResponse: HISTOGRAM_RESPONSE,
+ });
+
+ MerinoTestUtils.server.response = { status: 500 };
+ await QuickSuggest.weather._test_fetch();
+
+ assertEnabled({
+ message: "After fetch",
+ hasSuggestion: false,
+ pendingFetchCount: 0,
+ });
+ Assert.equal(
+ QuickSuggest.weather._test_merino.lastFetchStatus,
+ "http_error",
+ "The request failed with an HTTP error"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "http_error",
+ latencyRecorded: true,
+ client: QuickSuggest.weather._test_merino,
+ });
+
+ let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ // Clean up by forcing another fetch so the suggestion is non-null for the
+ // remaining tasks.
+ MerinoTestUtils.server.reset();
+ MerinoTestUtils.server.response.body.suggestions = [WEATHER_SUGGESTION];
+ await QuickSuggest.weather._test_fetch();
+ assertEnabled({
+ message: "On cleanup",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+});
+
+// A fetch that doesn't return a suggestion due to a client timeout should cause
+// the last-fetched suggestion to be discarded.
+add_tasks_with_rust(async function clientTimeout() {
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ let histograms = MerinoTestUtils.getAndClearHistograms({
+ extraLatency: HISTOGRAM_LATENCY,
+ extraResponse: HISTOGRAM_RESPONSE,
+ });
+
+ // Make the server return a delayed response so the Merino client times out
+ // waiting for it.
+ MerinoTestUtils.server.response.delay = 400;
+
+ // Make the client time out immediately.
+ QuickSuggest.weather._test_setTimeoutMs(1);
+
+ // Set up a promise that will be resolved when the client finally receives the
+ // response.
+ let responsePromise = QuickSuggest.weather._test_merino.waitForNextResponse();
+
+ await QuickSuggest.weather._test_fetch();
+
+ assertEnabled({
+ message: "After fetch",
+ hasSuggestion: false,
+ pendingFetchCount: 0,
+ });
+ Assert.equal(
+ QuickSuggest.weather._test_merino.lastFetchStatus,
+ "timeout",
+ "The request timed out"
+ );
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: "timeout",
+ latencyRecorded: false,
+ latencyStopwatchRunning: true,
+ client: QuickSuggest.weather._test_merino,
+ });
+
+ let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ // Await the response.
+ await responsePromise;
+
+ // The `checkAndClearHistograms()` call above cleared the histograms. After
+ // that, nothing else should have been recorded for the response.
+ MerinoTestUtils.checkAndClearHistograms({
+ histograms,
+ response: null,
+ latencyRecorded: true,
+ client: QuickSuggest.weather._test_merino,
+ });
+
+ QuickSuggest.weather._test_setTimeoutMs(-1);
+ delete MerinoTestUtils.server.response.delay;
+
+ // Clean up by forcing another fetch so the suggestion is non-null for the
+ // remaining tasks.
+ await QuickSuggest.weather._test_fetch();
+ assertEnabled({
+ message: "On cleanup",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+});
+
+// Locale task for when this test runs on an en-US OS.
+add_tasks_with_rust(async function locale_enUS() {
+ await doLocaleTest({
+ shouldRunTask: osLocale => osLocale == "en-US",
+ osUnit: "f",
+ unitsByLocale: {
+ "en-US": "f",
+ // When the app's locale is set to any en-* locale, F will be used because
+ // `regionalPrefsLocales` will prefer the en-US OS locale.
+ "en-CA": "f",
+ "en-GB": "f",
+ de: "c",
+ },
+ });
+});
+
+// Locale task for when this test runs on a non-US English OS.
+add_tasks_with_rust(async function locale_nonUSEnglish() {
+ await doLocaleTest({
+ shouldRunTask: osLocale => osLocale.startsWith("en") && osLocale != "en-US",
+ osUnit: "c",
+ unitsByLocale: {
+ // When the app's locale is set to en-US, C will be used because
+ // `regionalPrefsLocales` will prefer the non-US English OS locale.
+ "en-US": "c",
+ "en-CA": "c",
+ "en-GB": "c",
+ de: "c",
+ },
+ });
+});
+
+// Locale task for when this test runs on a non-English OS.
+add_tasks_with_rust(async function locale_nonEnglish() {
+ await doLocaleTest({
+ shouldRunTask: osLocale => !osLocale.startsWith("en"),
+ osUnit: "c",
+ unitsByLocale: {
+ "en-US": "f",
+ "en-CA": "c",
+ "en-GB": "c",
+ de: "c",
+ },
+ });
+});
+
+/**
+ * Testing locales is tricky due to the weather feature's use of
+ * `Services.locale.regionalPrefsLocales`. By default `regionalPrefsLocales`
+ * prefers the OS locale if its language is the same as the app locale's
+ * language; otherwise it prefers the app locale. For example, assuming the OS
+ * locale is en-CA, then if the app locale is en-US it will prefer en-CA since
+ * both are English, but if the app locale is de it will prefer de. If the pref
+ * `intl.regional_prefs.use_os_locales` is set, then the OS locale is always
+ * preferred.
+ *
+ * This function tests a given set of locales with and without
+ * `intl.regional_prefs.use_os_locales` set.
+ *
+ * @param {object} options
+ * Options
+ * @param {Function} options.shouldRunTask
+ * Called with the OS locale. Should return true if the function should run.
+ * Use this to skip tasks that don't target a desired OS locale.
+ * @param {string} options.osUnit
+ * The expected "c" or "f" unit for the OS locale.
+ * @param {object} options.unitsByLocale
+ * The expected "c" or "f" unit when the app's locale is set to particular
+ * locales. This should be an object that maps locales to expected units. For
+ * each locale in the object, the app's locale is set to that locale and the
+ * actual unit is expected to be the unit in the object.
+ */
+async function doLocaleTest({ shouldRunTask, osUnit, unitsByLocale }) {
+ Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true);
+ let osLocale = Services.locale.regionalPrefsLocales[0];
+ Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales");
+
+ if (!shouldRunTask(osLocale)) {
+ info("Skipping task, should not run for this OS locale");
+ return;
+ }
+
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ // Sanity check initial locale info.
+ Assert.equal(
+ Services.locale.appLocaleAsBCP47,
+ "en-US",
+ "Initial app locale should be en-US"
+ );
+ Assert.ok(
+ !Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales"),
+ "intl.regional_prefs.use_os_locales should be false initially"
+ );
+
+ // Check locales.
+ for (let [locale, temperatureUnit] of Object.entries(unitsByLocale)) {
+ await QuickSuggestTestUtils.withLocales([locale], async () => {
+ info("Checking locale: " + locale);
+ await check_results({
+ context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderWeather.name,
+ ],
+ isPrivate: false,
+ }),
+ matches: [makeWeatherResult({ temperatureUnit })],
+ });
+
+ info(
+ "Checking locale with intl.regional_prefs.use_os_locales: " + locale
+ );
+ Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true);
+ await check_results({
+ context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderWeather.name,
+ ],
+ isPrivate: false,
+ }),
+ matches: [makeWeatherResult({ temperatureUnit: osUnit })],
+ });
+ Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales");
+ });
+ }
+}
+
+// Blocks a result and makes sure the weather pref is disabled.
+add_tasks_with_rust(async function block() {
+ // Sanity check initial state.
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+ Assert.ok(
+ UrlbarPrefs.get("suggest.weather"),
+ "Sanity check: suggest.weather is true initially"
+ );
+
+ // Do a search so we can get an actual result.
+ let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [makeWeatherResult()],
+ });
+
+ // Block the result.
+ const controller = UrlbarTestUtils.newMockController();
+ controller.setView({
+ get visibleResults() {
+ return context.results;
+ },
+ controller: {
+ removeResult() {},
+ },
+ });
+ let result = context.results[0];
+ let provider = UrlbarProvidersManager.getProvider(result.providerName);
+ Assert.ok(provider, "Sanity check: Result provider found");
+ provider.onEngagement(
+ "engagement",
+ context,
+ {
+ result,
+ selType: "dismiss",
+ selIndex: context.results[0].rowIndex,
+ },
+ controller
+ );
+ Assert.ok(
+ !UrlbarPrefs.get("suggest.weather"),
+ "suggest.weather is false after blocking the result"
+ );
+
+ // Do a second search. Nothing should be returned.
+ context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ });
+ await check_results({
+ context,
+ matches: [],
+ });
+
+ // Re-enable the pref and clean up.
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ UrlbarPrefs.set("suggest.weather", true);
+ await fetchPromise;
+
+ // Wait for keywords to be re-synced from remote settings.
+ await QuickSuggestTestUtils.forceSync();
+
+ assertEnabled({
+ message: "On cleanup",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+});
+
+// Simulates wake 100ms before the start of the next fetch period. A new fetch
+// should not start.
+add_tasks_with_rust(async function wakeBeforeNextFetchPeriod() {
+ await doWakeTest({
+ sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs - 100,
+ shouldFetchOnWake: false,
+ fetchTimerMsOnWake: 100,
+ });
+});
+
+// Simulates wake 100ms after the start of the next fetch period. A new fetch
+// should start.
+add_tasks_with_rust(async function wakeAfterNextFetchPeriod() {
+ await doWakeTest({
+ sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs + 100,
+ shouldFetchOnWake: true,
+ });
+});
+
+// Simulates wake after many fetch periods + 100ms. A new fetch should start.
+add_tasks_with_rust(async function wakeAfterManyFetchPeriods() {
+ await doWakeTest({
+ sleepIntervalMs: 100 * QuickSuggest.weather._test_fetchIntervalMs + 100,
+ shouldFetchOnWake: true,
+ });
+});
+
+async function doWakeTest({
+ sleepIntervalMs,
+ shouldFetchOnWake,
+ fetchTimerMsOnWake,
+}) {
+ // Make `Date.now()` return a value under our control, doesn't matter what it
+ // is. This is the time the first fetch period will start.
+ let nowOnStart = 100;
+ let sandbox = sinon.createSandbox();
+ let dateNowStub = sandbox.stub(
+ Cu.getGlobalForObject(QuickSuggest.weather).Date,
+ "now"
+ );
+ dateNowStub.returns(nowOnStart);
+
+ // Start the first fetch period.
+ info("Starting first fetch period");
+ await QuickSuggest.weather._test_fetch();
+ Assert.equal(
+ QuickSuggest.weather._test_lastFetchTimeMs,
+ nowOnStart,
+ "Last fetch time should be updated after fetch"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchIntervalMs,
+ "Timer period should be full fetch interval"
+ );
+
+ let timer = QuickSuggest.weather._test_fetchTimer;
+
+ // Advance the clock and simulate wake.
+ info("Sending wake notification");
+ let nowOnWake = nowOnStart + sleepIntervalMs;
+ dateNowStub.returns(nowOnWake);
+ QuickSuggest.weather.observe(null, "wake_notification", "");
+
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "After wake, next fetch should not have immediately started"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_lastFetchTimeMs,
+ nowOnStart,
+ "After wake, last fetch time should be unchanged"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ 0,
+ "After wake, the timer should exist (be non-zero)"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ timer,
+ "After wake, a new timer should have been created"
+ );
+
+ if (shouldFetchOnWake) {
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs,
+ "After wake, timer period should be fetchDelayAfterComingOnlineMs"
+ );
+ } else {
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ fetchTimerMsOnWake,
+ "After wake, timer period should be the remaining interval"
+ );
+ }
+
+ // Wait for the fetch. If the wake didn't trigger it, then the caller should
+ // have passed in a `sleepIntervalMs` that will make it start soon.
+ info("Waiting for fetch after wake");
+ await QuickSuggest.weather.waitForFetches();
+
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchIntervalMs,
+ "After post-wake fetch, timer period should remain full fetch interval"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "After post-wake fetch, no more fetches should be pending"
+ );
+
+ dateNowStub.restore();
+}
+
+// When network:link-status-changed is observed and the suggestion is non-null,
+// a fetch should not start.
+add_tasks_with_rust(async function networkLinkStatusChanged_nonNull() {
+ // See nsINetworkLinkService for possible data values.
+ await doOnlineTestWithSuggestion({
+ topic: "network:link-status-changed",
+ dataValues: [
+ "down",
+ "up",
+ "changed",
+ "unknown",
+ "this is not a valid data value",
+ ],
+ });
+});
+
+// When network:offline-status-changed is observed and the suggestion is
+// non-null, a fetch should not start.
+add_tasks_with_rust(async function networkOfflineStatusChanged_nonNull() {
+ // See nsIIOService for possible data values.
+ await doOnlineTestWithSuggestion({
+ topic: "network:offline-status-changed",
+ dataValues: ["offline", "online", "this is not a valid data value"],
+ });
+});
+
+// When captive-portal-login-success is observed and the suggestion is non-null,
+// a fetch should not start.
+add_tasks_with_rust(async function captivePortalLoginSuccess_nonNull() {
+ // See nsIIOService for possible data values.
+ await doOnlineTestWithSuggestion({
+ topic: "captive-portal-login-success",
+ dataValues: [""],
+ });
+});
+
+async function doOnlineTestWithSuggestion({ topic, dataValues }) {
+ info("Starting fetch period");
+ await QuickSuggest.weather._test_fetch();
+ Assert.ok(
+ QuickSuggest.weather.suggestion,
+ "Suggestion should have been fetched"
+ );
+
+ let timer = QuickSuggest.weather._test_fetchTimer;
+
+ for (let data of dataValues) {
+ info("Sending notification: " + JSON.stringify({ topic, data }));
+ QuickSuggest.weather.observe(null, topic, data);
+
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "Fetch should not have started"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimer,
+ timer,
+ "Timer should not have been recreated"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchIntervalMs,
+ "Timer period should be the full fetch interval"
+ );
+ }
+}
+
+// When network:link-status-changed is observed and the suggestion is null, a
+// fetch should start unless the data indicates the status is offline.
+add_tasks_with_rust(async function networkLinkStatusChanged_null() {
+ // See nsINetworkLinkService for possible data values.
+ await doOnlineTestWithNullSuggestion({
+ topic: "network:link-status-changed",
+ offlineData: "down",
+ otherDataValues: [
+ "up",
+ "changed",
+ "unknown",
+ "this is not a valid data value",
+ ],
+ });
+});
+
+// When network:offline-status-changed is observed and the suggestion is null, a
+// fetch should start unless the data indicates the status is offline.
+add_tasks_with_rust(async function networkOfflineStatusChanged_null() {
+ // See nsIIOService for possible data values.
+ await doOnlineTestWithNullSuggestion({
+ topic: "network:offline-status-changed",
+ offlineData: "offline",
+ otherDataValues: ["online", "this is not a valid data value"],
+ });
+});
+
+// When captive-portal-login-success is observed and the suggestion is null, a
+// fetch should start.
+add_tasks_with_rust(async function captivePortalLoginSuccess_null() {
+ // See nsIIOService for possible data values.
+ await doOnlineTestWithNullSuggestion({
+ topic: "captive-portal-login-success",
+ otherDataValues: [""],
+ });
+});
+
+async function doOnlineTestWithNullSuggestion({
+ topic,
+ otherDataValues,
+ offlineData = "",
+}) {
+ QuickSuggest.weather._test_setSuggestionToNull();
+ Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");
+
+ let timer = QuickSuggest.weather._test_fetchTimer;
+
+ // First, send the notification with the offline data value. Nothing should
+ // happen.
+ if (offlineData) {
+ info("Sending notification: " + JSON.stringify({ topic, offlineData }));
+ QuickSuggest.weather.observe(null, topic, offlineData);
+
+ Assert.ok(
+ !QuickSuggest.weather.suggestion,
+ "Suggestion should remain null"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "Fetch should not have started"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimer,
+ timer,
+ "Timer should not have been recreated"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchIntervalMs,
+ "Timer period should be the full fetch interval"
+ );
+ }
+
+ // Now send it with all other data values. Fetches should be triggered.
+ for (let data of otherDataValues) {
+ QuickSuggest.weather._test_setSuggestionToNull();
+ Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");
+
+ info("Sending notification: " + JSON.stringify({ topic, data }));
+ QuickSuggest.weather.observe(null, topic, data);
+
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "Fetch should not have started yet"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ 0,
+ "Timer should exist"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ timer,
+ "A new timer should have been created"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs,
+ "Timer ms should be fetchDelayAfterComingOnlineMs"
+ );
+
+ timer = QuickSuggest.weather._test_fetchTimer;
+
+ info("Waiting for fetch after notification");
+ await QuickSuggest.weather.waitForFetches();
+
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "Fetch should not be pending"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ 0,
+ "Timer should exist"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ timer,
+ "A new timer should have been created"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchIntervalMs,
+ "Timer period should be full fetch interval"
+ );
+
+ timer = QuickSuggest.weather._test_fetchTimer;
+ }
+}
+
+// When many online notifications are received at once, only one fetch should
+// start.
+add_tasks_with_rust(async function manyOnlineNotifications() {
+ await doManyNotificationsTest([
+ ["network:link-status-changed", "changed"],
+ ["network:link-status-changed", "up"],
+ ["network:offline-status-changed", "online"],
+ ]);
+});
+
+// When wake and online notifications are received at once, only one fetch
+// should start.
+add_tasks_with_rust(async function wakeAndOnlineNotifications() {
+ await doManyNotificationsTest([
+ ["wake_notification", ""],
+ ["network:link-status-changed", "changed"],
+ ["network:link-status-changed", "up"],
+ ["network:offline-status-changed", "online"],
+ ]);
+});
+
+async function doManyNotificationsTest(notifications) {
+ // Make `Date.now()` return a value under our control, doesn't matter what it
+ // is. This is the time the first fetch period will start.
+ let nowOnStart = 100;
+ let sandbox = sinon.createSandbox();
+ let dateNowStub = sandbox.stub(
+ Cu.getGlobalForObject(QuickSuggest.weather).Date,
+ "now"
+ );
+ dateNowStub.returns(nowOnStart);
+
+ // Start a first fetch period so that after we send the notifications below
+ // the last fetch time will be in the past.
+ info("Starting first fetch period");
+ await QuickSuggest.weather._test_fetch();
+ Assert.equal(
+ QuickSuggest.weather._test_lastFetchTimeMs,
+ nowOnStart,
+ "Last fetch time should be updated after fetch"
+ );
+
+ // Now advance the clock by many fetch intervals.
+ let nowOnWake = nowOnStart + 100 * QuickSuggest.weather._test_fetchIntervalMs;
+ dateNowStub.returns(nowOnWake);
+
+ // Set the suggestion to null so online notifications will trigger a fetch.
+ QuickSuggest.weather._test_setSuggestionToNull();
+ Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");
+
+ // Clear the server's list of received requests.
+ MerinoTestUtils.server.reset();
+ MerinoTestUtils.server.response.body.suggestions = [
+ MerinoTestUtils.WEATHER_SUGGESTION,
+ ];
+
+ // Send the notifications.
+ for (let [topic, data] of notifications) {
+ info("Sending notification: " + JSON.stringify({ topic, data }));
+ QuickSuggest.weather.observe(null, topic, data);
+ }
+
+ info("Waiting for fetch after notifications");
+ await QuickSuggest.weather.waitForFetches();
+
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 0,
+ "Fetch should not be pending"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ 0,
+ "Timer should exist"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_fetchTimerMs,
+ QuickSuggest.weather._test_fetchIntervalMs,
+ "Timer period should be full fetch interval"
+ );
+
+ Assert.equal(
+ MerinoTestUtils.server.requests.length,
+ 1,
+ "Merino should have received only one request"
+ );
+
+ dateNowStub.restore();
+}
+
+// Fetching when a VPN is detected should set the suggestion to null, and
+// turning off the VPN should trigger a re-fetch.
+add_tasks_with_rust(async function vpn() {
+ // Register a mock object that implements nsINetworkLinkService.
+ let mockLinkService = {
+ isLinkUp: true,
+ linkStatusKnown: true,
+ linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI,
+ networkID: "abcd",
+ dnsSuffixList: [],
+ platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED,
+ QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]),
+ };
+ let networkLinkServiceCID = MockRegistrar.register(
+ "@mozilla.org/network/network-link-service;1",
+ mockLinkService
+ );
+ QuickSuggest.weather._test_linkService = mockLinkService;
+
+ // At this point no VPN is detected, so a fetch should complete successfully.
+ await QuickSuggest.weather._test_fetch();
+ Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should exist");
+
+ // Modify the mock link service to indicate a VPN is detected.
+ mockLinkService.platformDNSIndications =
+ Ci.nsINetworkLinkService.VPN_DETECTED;
+
+ // Now a fetch should set the suggestion to null.
+ await QuickSuggest.weather._test_fetch();
+ Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");
+
+ // Set `weather.ignoreVPN` and fetch again. It should complete successfully.
+ UrlbarPrefs.set("weather.ignoreVPN", true);
+ await QuickSuggest.weather._test_fetch();
+ Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched");
+
+ // Clear the pref and fetch again. It should set the suggestion back to null.
+ UrlbarPrefs.clear("weather.ignoreVPN");
+ await QuickSuggest.weather._test_fetch();
+ Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");
+
+ // Simulate the link status changing. Since the mock link service still
+ // indicates a VPN is detected, the suggestion should remain null.
+ let fetchPromise = QuickSuggest.weather.waitForFetches();
+ QuickSuggest.weather.observe(null, "network:link-status-changed", "changed");
+ await fetchPromise;
+ Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should remain null");
+
+ // Modify the mock link service to indicate a VPN is no longer detected.
+ mockLinkService.platformDNSIndications =
+ Ci.nsINetworkLinkService.NONE_DETECTED;
+
+ // Simulate the link status changing again. The suggestion should be fetched.
+ fetchPromise = QuickSuggest.weather.waitForFetches();
+ QuickSuggest.weather.observe(null, "network:link-status-changed", "changed");
+ await fetchPromise;
+ Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched");
+
+ MockRegistrar.unregister(networkLinkServiceCID);
+ delete QuickSuggest.weather._test_linkService;
+});
+
+// When a Nimbus experiment is installed, it should override the remote settings
+// weather record.
+add_tasks_with_rust(async function nimbusOverride() {
+ // Sanity check initial state.
+ assertEnabled({
+ message: "Sanity check initial state",
+ hasSuggestion: true,
+ pendingFetchCount: 0,
+ });
+
+ let defaultResult = makeWeatherResult();
+
+ // Verify a search works as expected with the default remote settings weather
+ // record (which was added in the init task).
+ await check_results({
+ context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [defaultResult],
+ });
+
+ // Install an experiment with a different keyword and min length.
+ let nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({
+ weatherKeywords: ["nimbusoverride"],
+ weatherKeywordsMinimumLength: "nimbus".length,
+ });
+
+ // The usual default keyword shouldn't match.
+ await check_results({
+ context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+
+ // The new keyword from Nimbus should match. Since keywords are defined in
+ // Nimbus, the result will be served from UrlbarProviderWeather and its source
+ // will be "merino", not "rust", even when Rust is enabled.
+ let merinoResult = makeWeatherResult({
+ source: "merino",
+ provider: "accuweather",
+ telemetryType: null,
+ });
+ await check_results({
+ context: createContext("nimbusoverride", {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [merinoResult],
+ });
+ await check_results({
+ context: createContext("nimbus", {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [merinoResult],
+ });
+
+ // Uninstall the experiment.
+ await nimbusCleanup();
+
+ // The usual default keyword should match again.
+ await check_results({
+ context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [defaultResult],
+ });
+
+ // The keywords from Nimbus shouldn't match anymore.
+ await check_results({
+ context: createContext("nimbusoverride", {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+ await check_results({
+ context: createContext("nimbus", {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [],
+ });
+});
+
+function assertEnabled({ message, hasSuggestion, pendingFetchCount }) {
+ info("Asserting feature is enabled");
+ if (message) {
+ info(message);
+ }
+
+ Assert.equal(
+ !!QuickSuggest.weather.suggestion,
+ hasSuggestion,
+ "Suggestion is null or non-null as expected"
+ );
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ 0,
+ "Fetch timer is non-zero"
+ );
+ Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null");
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ pendingFetchCount,
+ "Expected pending fetch count"
+ );
+}
+
+function assertDisabled({ message, pendingFetchCount }) {
+ info("Asserting feature is disabled");
+ if (message) {
+ info(message);
+ }
+
+ Assert.strictEqual(
+ QuickSuggest.weather.suggestion,
+ null,
+ "Suggestion is null"
+ );
+ Assert.strictEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ 0,
+ "Fetch timer is zero"
+ );
+ Assert.strictEqual(
+ QuickSuggest.weather._test_merino,
+ null,
+ "Merino client is null"
+ );
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ pendingFetchCount,
+ "Expected pending fetch count"
+ );
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js
new file mode 100644
index 0000000000..efa5922c3e
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/test_weather_keywords.js
@@ -0,0 +1,1503 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// Tests the keywords behavior of quick suggest weather.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs",
+});
+
+const { WEATHER_RS_DATA } = MerinoTestUtils;
+
+add_setup(async () => {
+ await QuickSuggestTestUtils.ensureQuickSuggestInit({
+ remoteSettingsRecords: [
+ {
+ type: "weather",
+ weather: WEATHER_RS_DATA,
+ },
+ ],
+ prefs: [["suggest.quicksuggest.nonsponsored", true]],
+ });
+ await MerinoTestUtils.initWeather();
+});
+
+// * Settings data: none
+// * Nimbus values: none
+// * Min keyword length pref: none
+// * Expected: no suggestion
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "No data",
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: false,
+ },
+ });
+});
+
+// * Settings data: empty
+// * Nimbus values: none
+// * Min keyword length pref: none
+// * Expected: no suggestion
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Empty settings",
+ settingsData: {},
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: false,
+ },
+ });
+});
+
+// * Settings data: keywords only
+// * Nimbus values: none
+// * Min keyword length pref: none
+// * Expected: full keywords only
+//
+// JS backend only. The Rust component expects settings data to contain
+// min_keyword_length.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function () {
+ await doKeywordsTest({
+ desc: "Settings only, keywords only",
+ settingsData: {
+ keywords: ["weather", "forecast"],
+ },
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: true,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: true,
+ },
+ });
+ }
+);
+
+// * Settings data: keywords and min keyword length = 0
+// * Nimbus values: none
+// * Min keyword length pref: none
+// * Expected: full keywords only
+//
+// JS backend only. The Rust component doesn't treat minKeywordLength == 0 as a
+// special case.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function () {
+ await doKeywordsTest({
+ desc: "Settings only, min keyword length = 0",
+ settingsData: {
+ keywords: ["weather", "forecast"],
+ min_keyword_length: 0,
+ },
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: true,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: true,
+ },
+ });
+ }
+);
+
+// * Settings data: keywords and min keyword length > 0
+// * Nimbus values: none
+// * Min keyword length pref: none
+// * Expected: use settings data
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings only, min keyword length > 0",
+ settingsData: {
+ keywords: ["weather", "forecast"],
+ min_keyword_length: 3,
+ },
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: true,
+ weat: true,
+ weath: true,
+ weathe: true,
+ weather: true,
+ f: false,
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: keywords and min keyword length = 0
+// * Nimbus values: none
+// * Min keyword length pref: 6
+// * Expected: use settings keywords and min keyword length pref
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings only, min keyword length = 0, pref exists",
+ settingsData: {
+ keywords: ["weather", "forecast"],
+ min_keyword_length: 0,
+ },
+ minKeywordLength: 6,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: true,
+ weather: true,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: keywords and min keyword length > 0
+// * Nimbus values: none
+// * Min keyword length pref: 6
+// * Expected: use settings keywords and min keyword length pref
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings only, min keyword length > 0, pref exists",
+ settingsData: {
+ keywords: ["weather", "forecast"],
+ min_keyword_length: 3,
+ },
+ minKeywordLength: 6,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: true,
+ weather: true,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: empty
+// * Nimbus values: empty
+// * Min keyword length pref: none
+// * Expected: no suggestion
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: empty; Nimbus: empty",
+ settingsData: {},
+ nimbusValues: {},
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: false,
+ },
+ });
+});
+
+// * Settings data: keywords only
+// * Nimbus values: keywords only
+// * Min keyword length pref: none
+// * Expected: full keywords in Nimbus
+//
+// JS backend only. The Rust component expects settings data to contain
+// min_keyword_length.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function () {
+ await doKeywordsTest({
+ desc: "Settings: keywords; Nimbus: keywords",
+ settingsData: {
+ keywords: ["weather"],
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast"],
+ },
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: true,
+ },
+ });
+ }
+);
+
+// * Settings data: keywords and min keyword length = 0
+// * Nimbus values: keywords only
+// * Min keyword length pref: none
+// * Expected: full keywords in Nimbus
+//
+// JS backend only. The Rust component doesn't treat minKeywordLength == 0 as a
+// special case.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function () {
+ await doKeywordsTest({
+ desc: "Settings: keywords, min keyword length = 0; Nimbus: keywords",
+ settingsData: {
+ keywords: ["weather"],
+ min_keyword_length: 0,
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast"],
+ },
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: true,
+ },
+ });
+ }
+);
+
+// * Settings data: keywords and min keyword length > 0
+// * Nimbus values: keywords only
+// * Min keyword length pref: none
+// * Expected: Nimbus keywords with settings min keyword length.
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords",
+ settingsData: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast"],
+ },
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: keywords and min keyword length > 0
+// * Nimbus values: keywords and min keyword length = 0
+// * Min keyword length pref: none
+// * Expected: Nimbus keywords with settings min keyword length.
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0",
+ settingsData: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast"],
+ weatherKeywordsMinimumLength: 0,
+ },
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: keywords and min keyword length > 0
+// * Nimbus values: keywords and min keyword length > 0
+// * Min keyword length pref: none
+// * Expected: use Nimbus values.
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0",
+ settingsData: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast"],
+ weatherKeywordsMinimumLength: 4,
+ },
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: keywords and min keyword length > 0
+// * Nimbus values: keywords and min keyword length = 0
+// * Min keyword length pref: exists
+// * Expected: Nimbus keywords with min keyword length pref.
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length = 0; pref exists",
+ settingsData: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast"],
+ weatherKeywordsMinimumLength: 0,
+ },
+ minKeywordLength: 6,
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: keywords and min keyword length > 0
+// * Nimbus values: keywords and min keyword length > 0
+// * Min keyword length pref: exists
+// * Expected: Nimbus keywords with min keyword length pref
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: keywords, min keyword length > 0; Nimbus: keywords, min keyword length > 0; pref exists",
+ settingsData: {
+ keywords: ["weather", "forecast"],
+ min_keyword_length: 3,
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast"],
+ weatherKeywordsMinimumLength: 4,
+ },
+ minKeywordLength: 6,
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: none
+// * Nimbus values: keywords only
+// * Min keyword length pref: none
+// * Expected: full keywords
+//
+// TODO bug 1879209: This doesn't work with the Rust backend because if
+// min_keyword_length isn't specified on ingest, the Rust database will retain
+// the last known good min_keyword_length, which interferes with this task.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function () {
+ await doKeywordsTest({
+ desc: "Settings: none; Nimbus: keywords",
+ nimbusValues: {
+ weatherKeywords: ["weather", "forecast"],
+ },
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: true,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: true,
+ },
+ });
+ }
+);
+
+// * Settings data: none
+// * Nimbus values: keywords and min keyword length = 0
+// * Min keyword length pref: none
+// * Expected: full keywords
+//
+// TODO bug 1879209: This doesn't work with the Rust backend because if
+// min_keyword_length isn't specified on ingest, the Rust database will retain
+// the last known good min_keyword_length, which interferes with this task.
+add_task(
+ {
+ skip_if: () => UrlbarPrefs.get("quickSuggestRustEnabled"),
+ },
+ async function () {
+ await doKeywordsTest({
+ desc: "Settings: none; Nimbus: keywords, min keyword length = 0",
+ nimbusValues: {
+ weatherKeywords: ["weather", "forecast"],
+ weatherKeywordsMinimumLength: 0,
+ },
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: true,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: true,
+ },
+ });
+ }
+);
+
+// * Settings data: none
+// * Nimbus values: keywords and min keyword length > 0
+// * Min keyword length pref: none
+// * Expected: use Nimbus values
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: none; Nimbus: keywords, min keyword length > 0",
+ nimbusValues: {
+ weatherKeywords: ["weather", "forecast"],
+ weatherKeywordsMinimumLength: 3,
+ },
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: true,
+ weat: true,
+ weath: true,
+ weathe: true,
+ weather: true,
+ f: false,
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// * Settings data: none
+// * Nimbus values: keywords and min keyword length > 0
+// * Min keyword length pref: exists
+// * Expected: use Nimbus keywords and min keyword length pref
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function () {
+ await doKeywordsTest({
+ desc: "Settings: none; Nimbus: keywords, min keyword length > 0; pref exists",
+ nimbusValues: {
+ weatherKeywords: ["weather", "forecast"],
+ weatherKeywordsMinimumLength: 3,
+ },
+ minKeywordLength: 6,
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: true,
+ weather: true,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ forecast: true,
+ },
+ });
+});
+
+// When `weatherKeywords` is non-null and `weatherKeywordsMinimumLength` is
+// larger than the length of all keywords, the suggestion should not be
+// triggered.
+// Even when Rust is enabled, UrlbarProviderWeather should serve the
+// suggestion since the keywords come from Nimbus.
+add_tasks_with_rust(async function minLength_large() {
+ await doKeywordsTest({
+ desc: "Large min length",
+ nimbusValues: {
+ weatherKeywords: ["weather", "forecast"],
+ weatherKeywordsMinimumLength: 999,
+ },
+ alwaysExpectMerinoResult: true,
+ tests: {
+ "": false,
+ w: false,
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ weathe: false,
+ weather: false,
+ f: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: false,
+ forecas: false,
+ forecast: false,
+ },
+ });
+});
+
+// Leading and trailing spaces should be ignored.
+add_tasks_with_rust(async function leadingAndTrailingSpaces() {
+ await doKeywordsTest({
+ settingsData: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ tests: {
+ " wea": true,
+ " wea": true,
+ "wea ": true,
+ "wea ": true,
+ " wea ": true,
+ " weat": true,
+ " weat": true,
+ "weat ": true,
+ "weat ": true,
+ " weat ": true,
+ },
+ });
+});
+
+add_tasks_with_rust(async function caseInsensitive() {
+ await doKeywordsTest({
+ desc: "Case insensitive",
+ settingsData: {
+ keywords: ["weather"],
+ min_keyword_length: 3,
+ },
+ tests: {
+ wea: true,
+ WEA: true,
+ Wea: true,
+ WeA: true,
+ WEATHER: true,
+ Weather: true,
+ WeAtHeR: true,
+ },
+ });
+});
+
+async function doKeywordsTest({
+ desc,
+ tests,
+ nimbusValues = null,
+ settingsData = null,
+ minKeywordLength = undefined,
+ alwaysExpectMerinoResult = false,
+}) {
+ info("Doing keywords test: " + desc);
+ info(JSON.stringify({ nimbusValues, settingsData, minKeywordLength }));
+
+ // If the JS backend is enabled, a suggestion hasn't already been fetched, and
+ // the data contains keywords, a fetch will start. Wait for it to finish later
+ // below.
+ let fetchPromise;
+ if (
+ !QuickSuggest.weather.suggestion &&
+ !UrlbarPrefs.get("quickSuggestRustEnabled") &&
+ (nimbusValues?.weatherKeywords || settingsData?.keywords)
+ ) {
+ fetchPromise = QuickSuggest.weather.waitForFetches();
+ }
+
+ let nimbusCleanup;
+ if (nimbusValues) {
+ nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues);
+ }
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: settingsData,
+ },
+ ]);
+
+ if (minKeywordLength) {
+ UrlbarPrefs.set("weather.minKeywordLength", minKeywordLength);
+ }
+
+ if (fetchPromise) {
+ info("Waiting for fetch");
+ assertFetchingStarted({ pendingFetchCount: 1 });
+ await fetchPromise;
+ info("Got fetch");
+ }
+
+ let expectedResult = makeWeatherResult(
+ !alwaysExpectMerinoResult
+ ? undefined
+ : { source: "merino", provider: "accuweather", telemetryType: null }
+ );
+
+ for (let [searchString, expected] of Object.entries(tests)) {
+ info(
+ "Doing keywords test search: " +
+ JSON.stringify({
+ searchString,
+ expected,
+ })
+ );
+
+ await check_results({
+ context: createContext(searchString, {
+ providers: [
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderWeather.name,
+ ],
+ isPrivate: false,
+ }),
+ matches: expected ? [expectedResult] : [],
+ });
+ }
+
+ await nimbusCleanup?.();
+
+ fetchPromise = null;
+ if (!QuickSuggest.weather.suggestion) {
+ fetchPromise = QuickSuggest.weather.waitForFetches();
+ }
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ]);
+
+ UrlbarPrefs.clear("weather.minKeywordLength");
+ await fetchPromise;
+}
+
+// When a sponsored quick suggest result matches the same keyword as the weather
+// result, the weather result should be shown and the quick suggest result
+// should not be shown.
+add_tasks_with_rust(async function matchingQuickSuggest_sponsored() {
+ await doMatchingQuickSuggestTest("suggest.quicksuggest.sponsored", true);
+});
+
+// When a non-sponsored quick suggest result matches the same keyword as the
+// weather result, the weather result should be shown and the quick suggest
+// result should not be shown.
+add_tasks_with_rust(async function matchingQuickSuggest_nonsponsored() {
+ await doMatchingQuickSuggestTest("suggest.quicksuggest.nonsponsored", false);
+});
+
+async function doMatchingQuickSuggestTest(pref, isSponsored) {
+ let keyword = "test";
+
+ let attachment = isSponsored
+ ? {
+ id: 1,
+ url: "http://example.com/amp",
+ title: "AMP Suggestion",
+ keywords: [keyword],
+ click_url: "http://example.com/amp-click",
+ impression_url: "http://example.com/amp-impression",
+ advertiser: "Amp",
+ iab_category: "22 - Shopping",
+ icon: "1234",
+ }
+ : {
+ id: 2,
+ url: "http://example.com/wikipedia",
+ title: "Wikipedia Suggestion",
+ keywords: [keyword],
+ click_url: "http://example.com/wikipedia-click",
+ impression_url: "http://example.com/wikipedia-impression",
+ advertiser: "Wikipedia",
+ iab_category: "5 - Education",
+ icon: "1234",
+ };
+
+ // Add a remote settings result to quick suggest.
+ let oldPrefValue = UrlbarPrefs.get(pref);
+ UrlbarPrefs.set(pref, true);
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "data",
+ attachment: [attachment],
+ },
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ]);
+
+ // First do a search to verify the quick suggest result matches the keyword.
+ let payload;
+ if (!UrlbarPrefs.get("quickSuggestRustEnabled")) {
+ payload = {
+ source: "remote-settings",
+ provider: "AdmWikipedia",
+ sponsoredImpressionUrl: attachment.impression_url,
+ sponsoredClickUrl: attachment.click_url,
+ sponsoredBlockId: attachment.id,
+ };
+ } else {
+ payload = {
+ source: "rust",
+ provider: isSponsored ? "Amp" : "Wikipedia",
+ };
+ if (isSponsored) {
+ payload.sponsoredImpressionUrl = attachment.impression_url;
+ payload.sponsoredClickUrl = attachment.click_url;
+ payload.sponsoredBlockId = attachment.id;
+ }
+ }
+
+ info("Doing first search for quick suggest result");
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ matches: [
+ {
+ type: UrlbarUtils.RESULT_TYPE.URL,
+ source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+ heuristic: false,
+ payload: {
+ ...payload,
+ telemetryType: isSponsored ? "adm_sponsored" : "adm_nonsponsored",
+ qsSuggestion: keyword,
+ title: attachment.title,
+ url: attachment.url,
+ displayUrl: attachment.url.replace(/[/]$/, ""),
+ originalUrl: attachment.url,
+ icon: null,
+ sponsoredAdvertiser: attachment.advertiser,
+ sponsoredIabCategory: attachment.iab_category,
+ isSponsored,
+ descriptionL10n: isSponsored
+ ? { id: "urlbar-result-action-sponsored" }
+ : undefined,
+ helpUrl: QuickSuggest.HELP_URL,
+ helpL10n: {
+ id: "urlbar-result-menu-learn-more-about-firefox-suggest",
+ },
+ isBlockable: true,
+ blockL10n: {
+ id: "urlbar-result-menu-dismiss-firefox-suggest",
+ },
+ },
+ },
+ ],
+ });
+
+ // Set up the keyword for the weather suggestion and do a second search to
+ // verify only the weather result matches.
+ info("Doing second search for weather suggestion");
+ let cleanup = await UrlbarTestUtils.initNimbusFeature({
+ weatherKeywords: [keyword],
+ weatherKeywordsMinimumLength: 1,
+ });
+ await check_results({
+ context: createContext(keyword, {
+ providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderWeather.name],
+ isPrivate: false,
+ }),
+ // The result should always come from Merino.
+ matches: [
+ makeWeatherResult({
+ source: "merino",
+ provider: "accuweather",
+ telemetryType: null,
+ }),
+ ],
+ });
+ await cleanup();
+
+ UrlbarPrefs.set(pref, oldPrefValue);
+}
+
+add_tasks_with_rust(async function () {
+ await doIncrementTest({
+ desc: "Settings only without cap",
+ setup: {
+ settingsData: {
+ weather: {
+ keywords: ["forecast", "wind"],
+ min_keyword_length: 3,
+ },
+ },
+ },
+ tests: [
+ {
+ minKeywordLength: 3,
+ canIncrement: true,
+ searches: {
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ wi: false,
+ win: true,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 4,
+ canIncrement: true,
+ searches: {
+ fo: false,
+ for: false,
+ fore: true,
+ forec: true,
+ wi: false,
+ win: false,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 5,
+ canIncrement: true,
+ searches: {
+ fo: false,
+ for: false,
+ fore: false,
+ forec: true,
+ wi: false,
+ win: false,
+ wind: false,
+ },
+ },
+ ],
+ });
+});
+
+add_tasks_with_rust(async function () {
+ await doIncrementTest({
+ desc: "Settings only with cap",
+ setup: {
+ settingsData: {
+ weather: {
+ keywords: ["forecast", "wind"],
+ min_keyword_length: 3,
+ },
+ configuration: {
+ show_less_frequently_cap: 3,
+ },
+ },
+ },
+ tests: [
+ {
+ minKeywordLength: 3,
+ canIncrement: true,
+ searches: {
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: true,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 4,
+ canIncrement: true,
+ searches: {
+ fo: false,
+ for: false,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 5,
+ canIncrement: true,
+ searches: {
+ fo: false,
+ for: false,
+ fore: false,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: false,
+ windy: false,
+ },
+ },
+ {
+ minKeywordLength: 6,
+ canIncrement: false,
+ searches: {
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: false,
+ windy: false,
+ },
+ },
+ {
+ minKeywordLength: 6,
+ canIncrement: false,
+ searches: {
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: false,
+ windy: false,
+ },
+ },
+ ],
+ });
+});
+
+add_tasks_with_rust(async function () {
+ await doIncrementTest({
+ desc: "Settings and Nimbus without cap",
+ setup: {
+ settingsData: {
+ weather: {
+ keywords: ["weather"],
+ min_keyword_length: 5,
+ },
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast", "wind"],
+ weatherKeywordsMinimumLength: 3,
+ },
+ },
+ // The suggestion should be served by UrlbarProviderWeather and therefore
+ // be from Merino.
+ alwaysExpectMerinoResult: true,
+ tests: [
+ {
+ minKeywordLength: 3,
+ canIncrement: true,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ wi: false,
+ win: true,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 4,
+ canIncrement: true,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: false,
+ fore: true,
+ forec: true,
+ wi: false,
+ win: false,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 5,
+ canIncrement: true,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: true,
+ wi: false,
+ win: false,
+ wind: false,
+ windy: false,
+ },
+ },
+ ],
+ });
+});
+
+add_task(async function () {
+ await doIncrementTest({
+ desc: "Settings and Nimbus with cap in Nimbus",
+ setup: {
+ settingsData: {
+ weather: {
+ keywords: ["weather"],
+ min_keyword_length: 5,
+ },
+ },
+ nimbusValues: {
+ weatherKeywords: ["forecast", "wind"],
+ weatherKeywordsMinimumLength: 3,
+ weatherKeywordsMinimumLengthCap: 6,
+ },
+ },
+ // The suggestion should be served by UrlbarProviderWeather and therefore
+ // be from Merino.
+ alwaysExpectMerinoResult: true,
+ tests: [
+ {
+ minKeywordLength: 3,
+ canIncrement: true,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: true,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: true,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 4,
+ canIncrement: true,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: false,
+ fore: true,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: true,
+ },
+ },
+ {
+ minKeywordLength: 5,
+ canIncrement: true,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: true,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: false,
+ windy: false,
+ },
+ },
+ {
+ minKeywordLength: 6,
+ canIncrement: false,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: false,
+ windy: false,
+ },
+ },
+ {
+ minKeywordLength: 6,
+ canIncrement: false,
+ searches: {
+ we: false,
+ wea: false,
+ weat: false,
+ weath: false,
+ fo: false,
+ for: false,
+ fore: false,
+ forec: false,
+ foreca: true,
+ forecas: true,
+ wi: false,
+ win: false,
+ wind: false,
+ windy: false,
+ },
+ },
+ ],
+ });
+});
+
+async function doIncrementTest({
+ desc,
+ setup,
+ tests,
+ alwaysExpectMerinoResult = false,
+}) {
+ info("Doing increment test: " + desc);
+ info(JSON.stringify({ setup }));
+
+ let { nimbusValues, settingsData } = setup;
+
+ // If the JS backend is enabled, a suggestion hasn't already been fetched, and
+ // the data contains keywords, a fetch will start. Wait for it to finish later
+ // below.
+ let fetchPromise;
+ if (
+ !QuickSuggest.weather.suggestion &&
+ !UrlbarPrefs.get("quickSuggestRustEnabled") &&
+ (nimbusValues?.weatherKeywords || settingsData?.weather?.keywords)
+ ) {
+ fetchPromise = QuickSuggest.weather.waitForFetches();
+ }
+
+ let nimbusCleanup;
+ if (nimbusValues) {
+ nimbusCleanup = await UrlbarTestUtils.initNimbusFeature(nimbusValues);
+ }
+
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: settingsData?.weather,
+ },
+ {
+ type: "configuration",
+ configuration: settingsData?.configuration,
+ },
+ ]);
+
+ if (fetchPromise) {
+ info("Waiting for fetch");
+ assertFetchingStarted({ pendingFetchCount: 1 });
+ await fetchPromise;
+ info("Got fetch");
+ }
+
+ let expectedResult = makeWeatherResult(
+ !alwaysExpectMerinoResult
+ ? undefined
+ : { source: "merino", provider: "accuweather", telemetryType: null }
+ );
+
+ for (let { minKeywordLength, canIncrement, searches } of tests) {
+ info(
+ "Doing increment test case: " +
+ JSON.stringify({
+ minKeywordLength,
+ canIncrement,
+ })
+ );
+
+ Assert.equal(
+ QuickSuggest.weather.minKeywordLength,
+ minKeywordLength,
+ "minKeywordLength should be correct"
+ );
+ Assert.equal(
+ QuickSuggest.weather.canIncrementMinKeywordLength,
+ canIncrement,
+ "canIncrement should be correct"
+ );
+
+ for (let [searchString, expected] of Object.entries(searches)) {
+ await check_results({
+ context: createContext(searchString, {
+ providers: [
+ UrlbarProviderQuickSuggest.name,
+ UrlbarProviderWeather.name,
+ ],
+ isPrivate: false,
+ }),
+ matches: expected ? [expectedResult] : [],
+ });
+ }
+
+ QuickSuggest.weather.incrementMinKeywordLength();
+ info(
+ "Incremented min keyword length, new value is: " +
+ QuickSuggest.weather.minKeywordLength
+ );
+ }
+
+ await nimbusCleanup?.();
+
+ fetchPromise = null;
+ if (!QuickSuggest.weather.suggestion) {
+ fetchPromise = QuickSuggest.weather.waitForFetches();
+ }
+ await QuickSuggestTestUtils.setRemoteSettingsRecords([
+ {
+ type: "weather",
+ weather: MerinoTestUtils.WEATHER_RS_DATA,
+ },
+ ]);
+ UrlbarPrefs.clear("weather.minKeywordLength");
+ await fetchPromise;
+}
+
+function assertFetchingStarted() {
+ info("Asserting fetching has started");
+
+ Assert.notEqual(
+ QuickSuggest.weather._test_fetchTimer,
+ 0,
+ "Fetch timer is non-zero"
+ );
+ Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null");
+ Assert.equal(
+ QuickSuggest.weather._test_pendingFetchCount,
+ 1,
+ "Expected pending fetch count"
+ );
+}
diff --git a/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml
new file mode 100644
index 0000000000..ceab478795
--- /dev/null
+++ b/browser/components/urlbar/tests/quicksuggest/unit/xpcshell.toml
@@ -0,0 +1,51 @@
+[DEFAULT]
+skip-if = ["os == 'android'"] # bug 1730213
+head = "../../unit/head.js head.js"
+firefox-appdir = "browser"
+
+["test_merinoClient.js"]
+
+["test_merinoClient_sessions.js"]
+
+["test_quicksuggest.js"]
+
+["test_quicksuggest_addons.js"]
+
+["test_quicksuggest_dynamicWikipedia.js"]
+
+["test_quicksuggest_impressionCaps.js"]
+skip-if = ["true"] # Bug 1880214
+
+["test_quicksuggest_mdn.js"]
+
+["test_quicksuggest_merino.js"]
+
+["test_quicksuggest_merinoSessions.js"]
+
+["test_quicksuggest_migrate_v1.js"]
+
+["test_quicksuggest_migrate_v2.js"]
+
+["test_quicksuggest_nonUniqueKeywords.js"]
+skip-if = ["true"] # Bug 1880214
+
+["test_quicksuggest_offlineDefault.js"]
+
+["test_quicksuggest_pocket.js"]
+
+["test_quicksuggest_positionInSuggestions.js"]
+skip-if = ["true"] # Bug 1880214
+
+["test_quicksuggest_scoreMap.js"]
+
+["test_quicksuggest_topPicks.js"]
+
+["test_quicksuggest_yelp.js"]
+
+["test_rust_ingest.js"]
+
+["test_suggestionsMap.js"]
+
+["test_weather.js"]
+
+["test_weather_keywords.js"]