diff options
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs | 1017 |
1 files changed, 1017 insertions, 0 deletions
diff --git a/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs new file mode 100644 index 0000000000..34da73e847 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/QuickSuggestTestUtils.sys.mjs @@ -0,0 +1,1017 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable mozilla/valid-lazy */ + +import { + CONTEXTUAL_SERVICES_PING_TYPES, + PartnerLinkAttribution, +} from "resource:///modules/PartnerLinkAttribution.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs", + ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + QuickSuggestRemoteSettings: + "resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarProviderQuickSuggest: + "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs", + UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +let gTestScope; + +// Test utils singletons need special handling. Since they are uninitialized in +// cleanup functions, they must be re-initialized on each new test. That does +// not happen automatically inside system modules like this one because system +// module lifetimes are the app's lifetime, unlike individual browser chrome and +// xpcshell tests. +Object.defineProperty(lazy, "UrlbarTestUtils", { + get: () => { + if (!lazy._UrlbarTestUtils) { + const { UrlbarTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" + ); + module.init(gTestScope); + gTestScope.registerCleanupFunction(() => { + // Make sure the utils are re-initialized during the next test. + lazy._UrlbarTestUtils = null; + }); + lazy._UrlbarTestUtils = module; + } + return lazy._UrlbarTestUtils; + }, +}); + +// Test utils singletons need special handling. Since they are uninitialized in +// cleanup functions, they must be re-initialized on each new test. That does +// not happen automatically inside system modules like this one because system +// module lifetimes are the app's lifetime, unlike individual browser chrome and +// xpcshell tests. +Object.defineProperty(lazy, "MerinoTestUtils", { + get: () => { + if (!lazy._MerinoTestUtils) { + const { MerinoTestUtils: module } = ChromeUtils.importESModule( + "resource://testing-common/MerinoTestUtils.sys.mjs" + ); + module.init(gTestScope); + gTestScope.registerCleanupFunction(() => { + // Make sure the utils are re-initialized during the next test. + lazy._MerinoTestUtils = null; + }); + lazy._MerinoTestUtils = module; + } + return lazy._MerinoTestUtils; + }, +}); + +const DEFAULT_CONFIG = {}; + +const BEST_MATCH_CONFIG = { + best_match: { + blocked_suggestion_ids: [], + min_search_string_length: 4, + }, +}; + +const DEFAULT_PING_PAYLOADS = { + [CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK]: { + advertiser: "testadvertiser", + block_id: 1, + context_id: () => actual => !!actual, + iab_category: "22 - Shopping", + improve_suggest_experience_checked: false, + match_type: "firefox-suggest", + request_id: null, + source: "remote-settings", + }, + [CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION]: { + advertiser: "testadvertiser", + block_id: 1, + context_id: () => actual => !!actual, + improve_suggest_experience_checked: false, + match_type: "firefox-suggest", + reporting_url: "https://example.com/click", + request_id: null, + source: "remote-settings", + }, + [CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION]: { + advertiser: "testadvertiser", + block_id: 1, + context_id: () => actual => !!actual, + improve_suggest_experience_checked: false, + is_clicked: false, + match_type: "firefox-suggest", + reporting_url: "https://example.com/impression", + request_id: null, + source: "remote-settings", + }, +}; + +// The following properties and methods are copied from the test scope to the +// test utils object so they can be easily accessed. Be careful about assuming a +// particular property will be defined because depending on the scope -- browser +// test or xpcshell test -- some may not be. +const TEST_SCOPE_PROPERTIES = [ + "Assert", + "EventUtils", + "info", + "registerCleanupFunction", +]; + +/** + * Mock RemoteSettings. + * + * @param {object} options + * Options object + * @param {object} options.config + * Dummy config in the RemoteSettings. + * @param {Array} options.data + * Dummy data in the RemoteSettings. + */ +class MockRemoteSettings { + constructor({ config = DEFAULT_CONFIG, data = [] }) { + this.#config = config; + this.#data = data; + + // Make a stub for "get" function to return dummy data. + const rs = lazy.RemoteSettings("quicksuggest"); + this.#sandbox = lazy.sinon.createSandbox(); + this.#sandbox.stub(rs, "get").callsFake(async query => { + return query.filters.type === "configuration" + ? [{ configuration: this.#config }] + : this.#data.filter(r => r.type === query.filters.type); + }); + + // Make a stub for "download" in attachments. + this.#sandbox.stub(rs.attachments, "download").callsFake(async record => { + if (!record.attachment) { + throw new Error("No attachmet in the record"); + } + const encoder = new TextEncoder(); + return { + buffer: encoder.encode(JSON.stringify(record.attachment)), + }; + }); + } + + async sync() { + if (!lazy.QuickSuggestRemoteSettings.rs) { + // There are no registered features that use remote settings. + return; + } + + // Observe config-set event to recognize that the config is synced. + const onConfigSync = new Promise(resolve => { + lazy.QuickSuggestRemoteSettings.emitter.once("config-set", resolve); + }); + + // Make a stub for each feature to recognize that the features are synced. + const features = lazy.QuickSuggestRemoteSettings.features; + const onFeatureSyncs = features.map(feature => { + return new Promise(resolve => { + const stub = this.#sandbox + .stub(feature, "onRemoteSettingsSync") + .callsFake(async (...args) => { + // Call and wait for the original function. + await stub.wrappedMethod.apply(feature, args); + stub.restore(); + resolve(); + }); + }); + }); + + // Force to sync. + const rs = lazy.RemoteSettings("quicksuggest"); + rs.emit("sync"); + + // Wait for sync. + await Promise.all([onConfigSync, ...onFeatureSyncs]); + } + + /* + * Update the config and data in RemoteSettings. If the config or the data are + * undefined, use the current one. + * + * @param {object} options + * Options object + * @param {object} options.config + * Dummy config in the RemoteSettings. + * @param {Array} options.data + * Dummy data in the RemoteSettings. + */ + async update({ config = this.#config, data = this.#data }) { + this.#config = config; + this.#data = data; + + await this.sync(); + } + + cleanup() { + this.#sandbox.restore(); + } + + #config = null; + #data = null; + #sandbox = null; +} + +/** + * Test utils for quick suggest. + */ +class _QuickSuggestTestUtils { + /** + * Initializes the utils. + * + * @param {object} scope + * The global JS scope where tests are being run. This allows the instance + * to access test helpers like `Assert` that are available in the scope. + */ + init(scope) { + if (!scope) { + throw new Error("QuickSuggestTestUtils() must be called with a scope"); + } + gTestScope = scope; + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = scope[p]; + } + // If you add other properties to `this`, null them in `uninit()`. + + Services.telemetry.clearScalars(); + + scope.registerCleanupFunction?.(() => this.uninit()); + } + + /** + * Uninitializes the utils. If they were created with a test scope that + * defines `registerCleanupFunction()`, you don't need to call this yourself + * because it will automatically be called as a cleanup function. Otherwise + * you'll need to call this. + */ + uninit() { + gTestScope = null; + for (let p of TEST_SCOPE_PROPERTIES) { + this[p] = null; + } + Services.telemetry.clearScalars(); + } + + get DEFAULT_CONFIG() { + // Return a clone so callers can modify it. + return Cu.cloneInto(DEFAULT_CONFIG, this); + } + + get BEST_MATCH_CONFIG() { + // Return a clone so callers can modify it. + return Cu.cloneInto(BEST_MATCH_CONFIG, this); + } + + /** + * Waits for quick suggest initialization to finish, ensures its data will not + * be updated again during the test, and also optionally sets it up with mock + * suggestions. + * + * @param {object} options + * Options object + * @param {Array} options.remoteSettingsResults + * Array of remote settings result objects. If not given, no suggestions + * will be present in remote settings. + * @param {Array} options.merinoSuggestions + * Array of Merino suggestion objects. If given, this function will start + * the mock Merino server and set `quicksuggest.dataCollection.enabled` to + * true so that `UrlbarProviderQuickSuggest` will fetch suggestions from it. + * Otherwise Merino will not serve suggestions, but you can still set up + * Merino without using this function by using `MerinoTestUtils` directly. + * @param {object} options.config + * The quick suggest configuration object. + * @returns {Function} + * A cleanup function. You only need to call this function if you're in a + * browser chrome test and you did not also call `init`. You can ignore it + * otherwise. + */ + async ensureQuickSuggestInit({ + remoteSettingsResults, + merinoSuggestions = null, + config = DEFAULT_CONFIG, + } = {}) { + this.#mockRemoteSettings = new MockRemoteSettings({ + config, + data: remoteSettingsResults, + }); + + this.info?.("ensureQuickSuggestInit calling QuickSuggest.init()"); + lazy.QuickSuggest.init(); + + // Sync with current data. + await this.#mockRemoteSettings.sync(); + + // Set up Merino. + if (merinoSuggestions) { + this.info?.("ensureQuickSuggestInit setting up Merino server"); + await lazy.MerinoTestUtils.server.start(); + lazy.MerinoTestUtils.server.response.body.suggestions = merinoSuggestions; + lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true); + this.info?.("ensureQuickSuggestInit done setting up Merino server"); + } + + let cleanup = async () => { + this.info?.("ensureQuickSuggestInit starting cleanup"); + this.#mockRemoteSettings.cleanup(); + if (merinoSuggestions) { + lazy.UrlbarPrefs.clear("quicksuggest.dataCollection.enabled"); + } + this.info?.("ensureQuickSuggestInit finished cleanup"); + }; + this.registerCleanupFunction?.(cleanup); + + return cleanup; + } + + /** + * Clears the current remote settings data and adds a new set of data. + * This can be used to add remote settings data after + * `ensureQuickSuggestInit()` has been called. + * + * @param {Array} data + * Array of remote settings data objects. + */ + async setRemoteSettingsResults(data) { + await this.#mockRemoteSettings.update({ data }); + } + + /** + * Sets the quick suggest configuration. You should call this again with + * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`. + * + * @param {object} config + * The config to be applied. See + */ + async setConfig(config) { + await this.#mockRemoteSettings.update({ config }); + } + + /** + * Sets the quick suggest configuration, calls your callback, and restores the + * previous configuration. + * + * @param {object} options + * The options object. + * @param {object} options.config + * The configuration that should be used with the callback + * @param {Function} options.callback + * Will be called with the configuration applied + * + * @see {@link setConfig} + */ + async withConfig({ config, callback }) { + let original = lazy.QuickSuggestRemoteSettings.config; + await this.setConfig(config); + await callback(); + await this.setConfig(original); + } + + /** + * Sets the Firefox Suggest scenario and waits for prefs to be updated. + * + * @param {string} scenario + * Pass falsey to reset the scenario to the default. + */ + async setScenario(scenario) { + // If we try to set the scenario before a previous update has finished, + // `updateFirefoxSuggestScenario` will bail, so wait. + await this.waitForScenarioUpdated(); + await lazy.UrlbarPrefs.updateFirefoxSuggestScenario({ scenario }); + } + + /** + * Waits for any prior scenario update to finish. + */ + async waitForScenarioUpdated() { + await lazy.TestUtils.waitForCondition( + () => !lazy.UrlbarPrefs.updatingFirefoxSuggestScenario, + "Waiting for updatingFirefoxSuggestScenario to be false" + ); + } + + /** + * Asserts a result is a quick suggest result. + * + * @param {object} [options] + * The options object. + * @param {string} options.url + * The expected URL. At least one of `url` and `originalUrl` must be given. + * @param {string} options.originalUrl + * The expected original URL (the URL with an unreplaced timestamp + * template). At least one of `url` and `originalUrl` must be given. + * @param {object} options.window + * The window that should be used for this assertion + * @param {number} [options.index] + * The expected index of the quick suggest result. Pass -1 to use the index + * of the last result. + * @param {boolean} [options.isSponsored] + * Whether the result is expected to be sponsored. + * @param {boolean} [options.isBestMatch] + * Whether the result is expected to be a best match. + * @returns {result} + * The quick suggest result. + */ + async assertIsQuickSuggest({ + url, + originalUrl, + window, + index = -1, + isSponsored = true, + isBestMatch = false, + } = {}) { + this.Assert.ok( + url || originalUrl, + "At least one of url and originalUrl is specified" + ); + + if (index < 0) { + let resultCount = lazy.UrlbarTestUtils.getResultCount(window); + if (isBestMatch) { + index = 1; + this.Assert.greater( + resultCount, + 1, + "Sanity check: Result count should be > 1" + ); + } else { + index = resultCount - 1; + this.Assert.greater( + resultCount, + 0, + "Sanity check: Result count should be > 0" + ); + } + } + + let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + let { result } = details; + + this.info?.( + `Checking actual result at index ${index}: ` + JSON.stringify(result) + ); + + this.Assert.equal( + result.providerName, + "UrlbarProviderQuickSuggest", + "Result provider name is UrlbarProviderQuickSuggest" + ); + this.Assert.equal(details.type, lazy.UrlbarUtils.RESULT_TYPE.URL); + this.Assert.equal(details.isSponsored, isSponsored, "Result isSponsored"); + if (url) { + this.Assert.equal(details.url, url, "Result URL"); + } + if (originalUrl) { + this.Assert.equal( + result.payload.originalUrl, + originalUrl, + "Result original URL" + ); + } + + this.Assert.equal(!!result.isBestMatch, isBestMatch, "Result isBestMatch"); + + let { row } = details.element; + + let sponsoredElement = isBestMatch + ? row._elements.get("bottom") + : row._elements.get("action"); + this.Assert.ok(sponsoredElement, "Result sponsored label element exists"); + this.Assert.equal( + sponsoredElement.textContent, + isSponsored ? "Sponsored" : "", + "Result sponsored label" + ); + + this.Assert.equal( + result.payload.helpUrl, + lazy.QuickSuggest.HELP_URL, + "Result helpURL" + ); + + if (lazy.UrlbarPrefs.get("resultMenu")) { + this.Assert.ok( + row._buttons.get("menu"), + "The menu button should be present" + ); + } else { + let helpButton = row._buttons.get("help"); + this.Assert.ok(helpButton, "The help button should be present"); + + let blockButton = row._buttons.get("block"); + if (!isBestMatch) { + this.Assert.equal( + !!blockButton, + lazy.UrlbarPrefs.get("quickSuggestBlockingEnabled"), + "The block button is present iff quick suggest blocking is enabled" + ); + } else { + this.Assert.equal( + !!blockButton, + lazy.UrlbarPrefs.get("bestMatchBlockingEnabled"), + "The block button is present iff best match blocking is enabled" + ); + } + } + + return details; + } + + /** + * Asserts a result is not a quick suggest result. + * + * @param {object} window + * The window that should be used for this assertion + * @param {number} index + * The index of the result. + */ + async assertIsNotQuickSuggest(window, index) { + let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt( + window, + index + ); + this.Assert.notEqual( + details.result.providerName, + "UrlbarProviderQuickSuggest", + `Result at index ${index} is not provided by UrlbarProviderQuickSuggest` + ); + } + + /** + * Asserts that none of the results are quick suggest results. + * + * @param {object} window + * The window that should be used for this assertion + */ + async assertNoQuickSuggestResults(window) { + for (let i = 0; i < lazy.UrlbarTestUtils.getResultCount(window); i++) { + await this.assertIsNotQuickSuggest(window, i); + } + } + + /** + * Checks the values of all the quick suggest telemetry keyed scalars and, + * if provided, other non-quick-suggest keyed scalars. Scalar values are all + * assumed to be 1. + * + * @param {object} expectedKeysByScalarName + * Maps scalar names to keys that are expected to be recorded. The value for + * each key is assumed to be 1. If you expect a scalar to be incremented, + * include it in this object; otherwise, don't include it. + */ + assertScalars(expectedKeysByScalarName) { + let scalars = lazy.TelemetryTestUtils.getProcessScalars( + "parent", + true, + true + ); + + // Check all quick suggest scalars. + expectedKeysByScalarName = { ...expectedKeysByScalarName }; + for (let scalarName of Object.values( + lazy.UrlbarProviderQuickSuggest.TELEMETRY_SCALARS + )) { + if (scalarName in expectedKeysByScalarName) { + lazy.TelemetryTestUtils.assertKeyedScalar( + scalars, + scalarName, + expectedKeysByScalarName[scalarName], + 1 + ); + delete expectedKeysByScalarName[scalarName]; + } else { + this.Assert.ok( + !(scalarName in scalars), + "Scalar should not be present: " + scalarName + ); + } + } + + // Check any other remaining scalars that were passed in. + for (let [scalarName, key] of Object.entries(expectedKeysByScalarName)) { + lazy.TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, key, 1); + } + } + + /** + * Checks quick suggest telemetry events. This is the same as + * `TelemetryTestUtils.assertEvents()` except it filters in only quick suggest + * events by default. If you are expecting events that are not in the quick + * suggest category, use `TelemetryTestUtils.assertEvents()` directly or pass + * in a filter override for `category`. + * + * @param {Array} expectedEvents + * List of expected telemetry events. + * @param {object} filterOverrides + * Extra properties to set in the filter object. + * @param {object} options + * The options object to pass to `TelemetryTestUtils.assertEvents()`. + */ + assertEvents(expectedEvents, filterOverrides = {}, options = undefined) { + lazy.TelemetryTestUtils.assertEvents( + expectedEvents, + { + category: lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY, + ...filterOverrides, + }, + options + ); + } + + /** + * Creates a `sinon.sandbox` and `sinon.spy` that can be used to instrument + * the quick suggest custom telemetry pings. If `init` was called with a test + * scope where `registerCleanupFunction` is defined, the sandbox will + * automically be restored at the end of the test. + * + * @returns {object} + * An object: { sandbox, spy, spyCleanup } + * `spyCleanup` is a cleanup function that should be called if you're in a + * browser chrome test and you did not also call `init`, or if you need to + * remove the spy before the test ends for some other reason. You can ignore + * it otherwise. + */ + createTelemetryPingSpy() { + let sandbox = lazy.sinon.createSandbox(); + let spy = sandbox.spy( + PartnerLinkAttribution._pingCentre, + "sendStructuredIngestionPing" + ); + let spyCleanup = () => sandbox.restore(); + this.registerCleanupFunction?.(spyCleanup); + return { sandbox, spy, spyCleanup }; + } + + /** + * Asserts that custom telemetry pings are recorded in the order they appear + * in the given `pings` array and that no other pings are recorded. + * + * @param {object} spy + * A `sinon.spy` object. See `createTelemetryPingSpy()`. This method resets + * the spy before returning. + * @param {Array} pings + * The expected pings in the order they are expected to be recorded. Each + * item in this array should be an object: `{ type, payload }` + * + * {string} type + * The ping's expected type, one of the `CONTEXTUAL_SERVICES_PING_TYPES` + * values. + * {object} payload + * The ping's expected payload. For convenience, you can leave out + * properties whose values are expected to be the default values defined + * in `DEFAULT_PING_PAYLOADS`. + */ + assertPings(spy, pings) { + let calls = spy.getCalls(); + this.Assert.equal( + calls.length, + pings.length, + "Expected number of ping calls" + ); + + for (let i = 0; i < pings.length; i++) { + let ping = pings[i]; + this.info?.( + `Checking ping at index ${i}, expected is: ` + JSON.stringify(ping) + ); + + // Add default properties to the expected payload for any that aren't + // already defined. + let { type, payload } = ping; + let defaultPayload = DEFAULT_PING_PAYLOADS[type]; + this.Assert.ok( + defaultPayload, + `Sanity check: Default payload exists for type: ${type}` + ); + payload = { ...defaultPayload, ...payload }; + + // Check the endpoint URL. + let call = calls[i]; + let endpointURL = call.args[1]; + this.Assert.ok( + endpointURL.includes(type), + `Endpoint URL corresponds to the expected ping type: ${type}` + ); + + // Check the payload. + let actualPayload = call.args[0]; + this._assertPingPayload(actualPayload, payload); + } + + spy.resetHistory(); + } + + /** + * Helper for checking contextual services ping payloads. + * + * @param {object} actualPayload + * The actual payload in the ping. + * @param {object} expectedPayload + * An object describing the expected payload. Non-function values in this + * object are checked for equality against the corresponding actual payload + * values. Function values are called and passed the corresponding actual + * values and should return true if the actual values are correct. + */ + _assertPingPayload(actualPayload, expectedPayload) { + this.info?.( + "Checking ping payload. Actual: " + + JSON.stringify(actualPayload) + + " -- Expected (excluding function properties): " + + JSON.stringify(expectedPayload) + ); + + this.Assert.equal( + Object.entries(actualPayload).length, + Object.entries(expectedPayload).length, + "Payload has expected number of properties" + ); + + for (let [key, expectedValue] of Object.entries(expectedPayload)) { + let actualValue = actualPayload[key]; + if (typeof expectedValue == "function") { + this.Assert.ok(expectedValue(actualValue), "Payload property: " + key); + } else { + this.Assert.equal( + actualValue, + expectedValue, + "Payload property: " + key + ); + } + } + } + + /** + * Asserts that URLs in a result's payload have the timestamp template + * substring replaced with real timestamps. + * + * @param {UrlbarResult} result The results to check + * @param {object} urls + * An object that contains the expected payload properties with template + * substrings. For example: + * ```js + * { + * url: "http://example.com/foo-%YYYYMMDDHH%", + * sponsoredClickUrl: "http://example.com/bar-%YYYYMMDDHH%", + * } + * ``` + */ + assertTimestampsReplaced(result, urls) { + let { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = lazy.QuickSuggest; + + // Parse the timestamp strings from each payload property and save them in + // `urls[key].timestamp`. + urls = { ...urls }; + for (let [key, url] of Object.entries(urls)) { + let index = url.indexOf(TIMESTAMP_TEMPLATE); + this.Assert.ok( + index >= 0, + `Timestamp template ${TIMESTAMP_TEMPLATE} is in URL ${url} for key ${key}` + ); + let value = result.payload[key]; + this.Assert.ok(value, "Key is in result payload: " + key); + let timestamp = value.substring(index, index + TIMESTAMP_LENGTH); + + // Set `urls[key]` to an object that's helpful in the logged info message + // below. + urls[key] = { url, value, timestamp }; + } + + this.info?.("Parsed timestamps: " + JSON.stringify(urls)); + + // Make a set of unique timestamp strings. There should only be one. + let { timestamp } = Object.values(urls)[0]; + this.Assert.deepEqual( + [...new Set(Object.values(urls).map(o => o.timestamp))], + [timestamp], + "There's only one unique timestamp string" + ); + + // Parse the parts of the timestamp string. + let year = timestamp.slice(0, -6); + let month = timestamp.slice(-6, -4); + let day = timestamp.slice(-4, -2); + let hour = timestamp.slice(-2); + let date = new Date(year, month - 1, day, hour); + + // The timestamp should be no more than two hours in the past. Typically it + // will be the same as the current hour, but since its resolution is in + // terms of hours and it's possible the test may have crossed over into a + // new hour as it was running, allow for the previous hour. + this.Assert.less( + Date.now() - 2 * 60 * 60 * 1000, + date.getTime(), + "Timestamp is within the past two hours" + ); + } + + /** + * Calls a callback while enrolled in a mock Nimbus experiment. The experiment + * is automatically unenrolled and cleaned up after the callback returns. + * + * @param {object} options + * Options for the mock experiment. + * @param {Function} options.callback + * The callback to call while enrolled in the mock experiment. + * @param {object} options.options + * See {@link enrollExperiment}. + */ + async withExperiment({ callback, ...options }) { + let doExperimentCleanup = await this.enrollExperiment(options); + await callback(); + await doExperimentCleanup(); + } + + /** + * Enrolls in a mock Nimbus experiment. + * + * @param {object} options + * Options for the mock experiment. + * @param {object} [options.valueOverrides] + * Values for feature variables. + * @returns {Promise<Function>} + * The experiment cleanup function (async). + */ + async enrollExperiment({ valueOverrides = {} }) { + this.info?.("Awaiting ExperimentAPI.ready"); + await lazy.ExperimentAPI.ready(); + + // Wait for any prior scenario updates to finish. If updates are ongoing, + // UrlbarPrefs will ignore the Nimbus update when the experiment is + // installed. This shouldn't be a problem in practice because in reality + // scenario updates are triggered only on app startup and Nimbus + // enrollments, but tests can trigger lots of updates back to back. + await this.waitForScenarioUpdated(); + + let doExperimentCleanup = + await lazy.ExperimentFakes.enrollWithFeatureConfig({ + enabled: true, + featureId: "urlbar", + value: valueOverrides, + }); + + // Wait for the pref updates triggered by the experiment enrollment. + this.info?.("Awaiting update after enrolling in experiment"); + await this.waitForScenarioUpdated(); + + return async () => { + this.info?.("Awaiting experiment cleanup"); + await doExperimentCleanup(); + + // The same pref updates will be triggered by unenrollment, so wait for + // them again. + this.info?.("Awaiting update after unenrolling in experiment"); + await this.waitForScenarioUpdated(); + }; + } + + /** + * Clears the Nimbus exposure event. + */ + async clearExposureEvent() { + // Exposure event recording is queued to the idle thread, so wait for idle + // before we start so any events from previous tasks will have been recorded + // and won't interfere with this task. + await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve)); + + Services.telemetry.clearEvents(); + lazy.NimbusFeatures.urlbar._didSendExposureEvent = false; + lazy.QuickSuggest._recordedExposureEvent = false; + } + + /** + * Asserts the Nimbus exposure event is recorded or not as expected. + * + * @param {boolean} expectedRecorded + * Whether the event is expected to be recorded. + */ + async assertExposureEvent(expectedRecorded) { + this.Assert.equal( + lazy.QuickSuggest._recordedExposureEvent, + expectedRecorded, + "_recordedExposureEvent is correct" + ); + + let filter = { + category: "normandy", + method: "expose", + object: "nimbus_experiment", + }; + + let expectedEvents = []; + if (expectedRecorded) { + expectedEvents.push({ + ...filter, + extra: { + branchSlug: "control", + featureId: "urlbar", + }, + }); + } + + // The event recording is queued to the idle thread when the search starts, + // so likewise queue the assert to idle instead of doing it immediately. + await new Promise(resolve => { + Services.tm.idleDispatchToMainThread(() => { + lazy.TelemetryTestUtils.assertEvents(expectedEvents, filter); + resolve(); + }); + }); + } + + /** + * Sets the app's locales, calls your callback, and resets locales. + * + * @param {Array} locales + * An array of locale strings. The entire array will be set as the available + * locales, and the first locale in the array will be set as the requested + * locale. + * @param {Function} callback + * The callback to be called with the {@link locales} set. This function can + * be async. + */ + async withLocales(locales, callback) { + let promiseChanges = async desiredLocales => { + this.info?.( + "Changing locales from " + + JSON.stringify(Services.locale.requestedLocales) + + " to " + + JSON.stringify(desiredLocales) + ); + + if (desiredLocales[0] == Services.locale.requestedLocales[0]) { + // Nothing happens when the locale doesn't actually change. + return; + } + + this.info?.("Waiting for intl:requested-locales-changed"); + await lazy.TestUtils.topicObserved("intl:requested-locales-changed"); + this.info?.("Observed intl:requested-locales-changed"); + + // Wait for the search service to reload engines. Otherwise tests can fail + // in strange ways due to internal search service state during shutdown. + // It won't always reload engines but it's hard to tell in advance when it + // won't, so also set a timeout. + this.info?.("Waiting for TOPIC_SEARCH_SERVICE"); + await Promise.race([ + lazy.TestUtils.topicObserved( + lazy.SearchUtils.TOPIC_SEARCH_SERVICE, + (subject, data) => { + this.info?.("Observed TOPIC_SEARCH_SERVICE with data: " + data); + return data == "engines-reloaded"; + } + ), + new Promise(resolve => { + lazy.setTimeout(() => { + this.info?.("Timed out waiting for TOPIC_SEARCH_SERVICE"); + resolve(); + }, 2000); + }), + ]); + + this.info?.("Done waiting for locale changes"); + }; + + let available = Services.locale.availableLocales; + let requested = Services.locale.requestedLocales; + + let newRequested = locales.slice(0, 1); + let promise = promiseChanges(newRequested); + Services.locale.availableLocales = locales; + Services.locale.requestedLocales = newRequested; + await promise; + + this.Assert.equal( + Services.locale.appLocaleAsBCP47, + locales[0], + "App locale is now " + locales[0] + ); + + await callback(); + + promise = promiseChanges(requested); + Services.locale.availableLocales = available; + Services.locale.requestedLocales = requested; + await promise; + } + + #mockRemoteSettings = null; +} + +export var QuickSuggestTestUtils = new _QuickSuggestTestUtils(); |