From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../quicksuggest/QuickSuggestTestUtils.sys.mjs | 915 +++++++++++++++++++++ 1 file changed, 915 insertions(+) create mode 100644 browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs (limited to 'browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs') 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} + * 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(); -- cgit v1.2.3