diff options
Diffstat (limited to 'browser/components/urlbar/tests/quicksuggest')
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"] |