1 /* Any copyright is dedicated to the Public Domain.
2 http://creativecommons.org/publicdomain/zero/1.0/ */
4 /* eslint-disable mozilla/valid-lazy */
7 CONTEXTUAL_SERVICES_PING_TYPES,
8 PartnerLinkAttribution,
9 } from "resource:///modules/PartnerLinkAttribution.sys.mjs";
13 ChromeUtils.defineESModuleGetters(lazy, {
14 ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
15 ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs",
16 NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
17 QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
18 RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
19 SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
20 Suggestion: "resource://gre/modules/RustSuggest.sys.mjs",
21 SuggestStore: "resource://gre/modules/RustSuggest.sys.mjs",
22 TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
23 TestUtils: "resource://testing-common/TestUtils.sys.mjs",
24 UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
25 UrlbarProviderQuickSuggest:
26 "resource:///modules/UrlbarProviderQuickSuggest.sys.mjs",
27 UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.sys.mjs",
28 UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
29 setTimeout: "resource://gre/modules/Timer.sys.mjs",
30 sinon: "resource://testing-common/Sinon.sys.mjs",
35 // Test utils singletons need special handling. Since they are uninitialized in
36 // cleanup functions, they must be re-initialized on each new test. That does
37 // not happen automatically inside system modules like this one because system
38 // module lifetimes are the app's lifetime, unlike individual browser chrome and
40 Object.defineProperty(lazy, "UrlbarTestUtils", {
42 if (!lazy._UrlbarTestUtils) {
43 const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
44 "resource://testing-common/UrlbarTestUtils.sys.mjs"
46 module.init(gTestScope);
47 gTestScope.registerCleanupFunction(() => {
48 // Make sure the utils are re-initialized during the next test.
49 lazy._UrlbarTestUtils = null;
51 lazy._UrlbarTestUtils = module;
53 return lazy._UrlbarTestUtils;
57 // Test utils singletons need special handling. Since they are uninitialized in
58 // cleanup functions, they must be re-initialized on each new test. That does
59 // not happen automatically inside system modules like this one because system
60 // module lifetimes are the app's lifetime, unlike individual browser chrome and
62 Object.defineProperty(lazy, "MerinoTestUtils", {
64 if (!lazy._MerinoTestUtils) {
65 const { MerinoTestUtils: module } = ChromeUtils.importESModule(
66 "resource://testing-common/MerinoTestUtils.sys.mjs"
68 module.init(gTestScope);
69 gTestScope.registerCleanupFunction(() => {
70 // Make sure the utils are re-initialized during the next test.
71 lazy._MerinoTestUtils = null;
73 lazy._MerinoTestUtils = module;
75 return lazy._MerinoTestUtils;
79 const DEFAULT_CONFIG = {};
81 const BEST_MATCH_CONFIG = {
83 blocked_suggestion_ids: [],
84 min_search_string_length: 4,
88 const DEFAULT_PING_PAYLOADS = {
89 [CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK]: {
90 advertiser: "testadvertiser",
92 context_id: () => actual => !!actual,
93 iab_category: "22 - Shopping",
94 improve_suggest_experience_checked: false,
95 match_type: "firefox-suggest",
97 source: "remote-settings",
99 [CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION]: {
100 advertiser: "testadvertiser",
102 context_id: () => actual => !!actual,
103 improve_suggest_experience_checked: false,
104 match_type: "firefox-suggest",
105 reporting_url: "https://example.com/click",
107 source: "remote-settings",
109 [CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION]: {
110 advertiser: "testadvertiser",
112 context_id: () => actual => !!actual,
113 improve_suggest_experience_checked: false,
115 match_type: "firefox-suggest",
116 reporting_url: "https://example.com/impression",
118 source: "remote-settings",
122 // The following properties and methods are copied from the test scope to the
123 // test utils object so they can be easily accessed. Be careful about assuming a
124 // particular property will be defined because depending on the scope -- browser
125 // test or xpcshell test -- some may not be.
126 const TEST_SCOPE_PROPERTIES = [
130 "registerCleanupFunction",
134 * Mock RemoteSettings.
136 * @param {object} options
138 * @param {object} options.config
139 * Dummy config in the RemoteSettings.
140 * @param {Array} options.data
141 * Dummy data in the RemoteSettings.
143 class MockRemoteSettings {
144 constructor({ config = DEFAULT_CONFIG, data = [] }) {
145 this.#config = config;
148 // Make a stub for "get" function to return dummy data.
149 const rs = lazy.RemoteSettings("quicksuggest");
150 this.#sandbox = lazy.sinon.createSandbox();
151 this.#sandbox.stub(rs, "get").callsFake(async query => {
152 return query.filters.type === "configuration"
153 ? [{ configuration: this.#config }]
154 : this.#data.filter(r => r.type === query.filters.type);
157 // Make a stub for "download" in attachments.
158 this.#sandbox.stub(rs.attachments, "download").callsFake(async record => {
159 if (!record.attachment) {
160 throw new Error("No attachmet in the record");
162 const encoder = new TextEncoder();
164 buffer: encoder.encode(JSON.stringify(record.attachment)),
170 if (!lazy.QuickSuggest.jsBackend.rs) {
171 // There are no registered features that use remote settings.
175 // Observe config-set event to recognize that the config is synced.
176 const onConfigSync = new Promise(resolve => {
177 lazy.QuickSuggest.jsBackend.emitter.once("config-set", resolve);
180 // Make a stub for each feature to recognize that the features are synced.
181 const features = lazy.QuickSuggest.jsBackend.features;
182 const onFeatureSyncs = features.map(feature => {
183 return new Promise(resolve => {
184 const stub = this.#sandbox
185 .stub(feature, "onRemoteSettingsSync")
186 .callsFake(async (...args) => {
187 // Call and wait for the original function.
188 await stub.wrappedMethod.apply(feature, args);
196 const rs = lazy.RemoteSettings("quicksuggest");
200 await Promise.all([onConfigSync, ...onFeatureSyncs]);
204 * Update the config and data in RemoteSettings. If the config or the data are
205 * undefined, use the current one.
207 * @param {object} options
209 * @param {object} options.config
210 * Dummy config in the RemoteSettings.
211 * @param {Array} options.data
212 * Dummy data in the RemoteSettings.
214 async update({ config = this.#config, data = this.#data }) {
215 this.#config = config;
222 this.#sandbox.restore();
231 * Mock `RustSuggest` implementation.
233 * @param {object} options
235 * @param {Array} options.data
236 * Mock remote settings records.
238 class MockRustSuggest {
239 constructor({ data = [] }) {
242 this.#sandbox = lazy.sinon.createSandbox();
243 this.#sandbox.stub(lazy.SuggestStore, "init").returns(this);
247 * Updates the mock data.
249 * @param {object} options
251 * @param {Array} options.data
252 * Mock remote settings records.
254 async update({ data }) {
259 this.#sandbox.restore();
262 // `RustSuggest` methods below.
265 return Promise.resolve();
273 let records = this.#data.filter(record => record.type == "data");
274 let suggestions = records
275 .map(record => record.attachment)
277 .filter(suggestion => suggestion.keywords.includes(query.keyword));
279 let matchedSuggestions = [];
280 for (let suggestion of suggestions) {
281 let isSponsored = suggestion.hasOwnProperty("is_sponsored")
282 ? suggestion.is_sponsored
283 : suggestion.iab_category == "22 - Shopping";
285 (isSponsored && query.includeSponsored) ||
286 (!isSponsored && query.includeNonSponsored)
288 matchedSuggestions.push(
290 ? new lazy.Suggestion.Amp(
294 query.keyword, // fullKeyword
295 suggestion.id, // blockId
296 suggestion.advertiser,
297 suggestion.iab_category,
298 suggestion.impression_url,
301 : new lazy.Suggestion.Wikipedia(
305 query.keyword // fullKeyword
310 return matchedSuggestions;
318 * Test utils for quick suggest.
320 class _QuickSuggestTestUtils {
322 * Initializes the utils.
324 * @param {object} scope
325 * The global JS scope where tests are being run. This allows the instance
326 * to access test helpers like `Assert` that are available in the scope.
330 throw new Error("QuickSuggestTestUtils() must be called with a scope");
333 for (let p of TEST_SCOPE_PROPERTIES) {
336 // If you add other properties to `this`, null them in `uninit()`.
338 Services.telemetry.clearScalars();
340 scope.registerCleanupFunction?.(() => this.uninit());
344 * Uninitializes the utils. If they were created with a test scope that
345 * defines `registerCleanupFunction()`, you don't need to call this yourself
346 * because it will automatically be called as a cleanup function. Otherwise
347 * you'll need to call this.
351 for (let p of TEST_SCOPE_PROPERTIES) {
354 Services.telemetry.clearScalars();
357 get DEFAULT_CONFIG() {
358 // Return a clone so callers can modify it.
359 return Cu.cloneInto(DEFAULT_CONFIG, this);
362 get BEST_MATCH_CONFIG() {
363 // Return a clone so callers can modify it.
364 return Cu.cloneInto(BEST_MATCH_CONFIG, this);
368 * Waits for quick suggest initialization to finish, ensures its data will not
369 * be updated again during the test, and also optionally sets it up with mock
372 * @param {object} options
374 * @param {Array} options.remoteSettingsResults
375 * Array of remote settings result objects. If not given, no suggestions
376 * will be present in remote settings.
377 * @param {Array} options.merinoSuggestions
378 * Array of Merino suggestion objects. If given, this function will start
379 * the mock Merino server and set `quicksuggest.dataCollection.enabled` to
380 * true so that `UrlbarProviderQuickSuggest` will fetch suggestions from it.
381 * Otherwise Merino will not serve suggestions, but you can still set up
382 * Merino without using this function by using `MerinoTestUtils` directly.
383 * @param {object} options.config
384 * The quick suggest configuration object.
385 * @param {object} options.rustEnabled
386 * Whether the Rust backend should be enabled. If false, the JS backend will
387 * be used. (There's no way to tell this function not to change the backend.
388 * If you need that, please modify this function to support it!)
389 * @returns {Function}
390 * An async cleanup function. This function is automatically registered as
391 * a cleanup function, so you only need to call it if your test needs to
392 * clean up quick suggest before it ends, for example if you have a small
393 * number of tasks that need quick suggest and it's not enabled throughout
394 * your test. The cleanup function is idempotent so there's no harm in
395 * calling it more than once. Be sure to `await` it.
397 async ensureQuickSuggestInit({
398 remoteSettingsResults,
399 merinoSuggestions = null,
400 config = DEFAULT_CONFIG,
403 this.#mockRemoteSettings = new MockRemoteSettings({
405 data: remoteSettingsResults,
407 this.#mockRustSuggest = new MockRustSuggest({
408 data: remoteSettingsResults,
411 this.info?.("ensureQuickSuggestInit calling QuickSuggest.init()");
412 lazy.QuickSuggest.init();
414 // Set the Rust pref and wait for the backend to become enabled/disabled.
415 // This must happen after setting up `MockRustSuggest`. Otherwise the real
416 // Rust component will be used and the Rust remote settings client will try
417 // to access the real remote settings server on ingestion.
419 "ensureQuickSuggestInit setting rustEnabled and awaiting enablePromise"
421 lazy.UrlbarPrefs.set("quicksuggest.rustEnabled", rustEnabled);
422 await lazy.QuickSuggest.rustBackend.enablePromise;
423 this.info?.("ensureQuickSuggestInit done awaiting enablePromise");
426 // Sync with current data.
427 this.info?.("ensureQuickSuggestInit syncing MockRemoteSettings");
428 await this.#mockRemoteSettings.sync();
429 this.info?.("ensureQuickSuggestInit done syncing MockRemoteSettings");
433 if (merinoSuggestions) {
434 this.info?.("ensureQuickSuggestInit setting up Merino server");
435 await lazy.MerinoTestUtils.server.start();
436 lazy.MerinoTestUtils.server.response.body.suggestions = merinoSuggestions;
437 lazy.UrlbarPrefs.set("quicksuggest.dataCollection.enabled", true);
438 this.info?.("ensureQuickSuggestInit done setting up Merino server");
441 let cleanupCalled = false;
442 let cleanup = async () => {
443 if (!cleanupCalled) {
444 cleanupCalled = true;
445 await this.#uninitQuickSuggest(!!merinoSuggestions);
448 this.registerCleanupFunction?.(cleanup);
453 async #uninitQuickSuggest(clearDataCollectionEnabled) {
454 this.info?.("uninitQuickSuggest started");
456 // We need to reset the Rust enabled status. If the status changes, it will
457 // either trigger the Rust backend to enable itself and ingest from remote
458 // settings (if Rust was disabled) or trigger the JS backend to enable
459 // itself and re-sync all features (if Rust was enabled). Wait for each to
460 // finish *before* cleaning up MockRustSuggest and MockRemoteSettings. This
461 // will ensure that all activity has stopped before this function returns.
462 let rustEnabled = lazy.UrlbarPrefs.get("quicksuggest.rustEnabled");
463 lazy.UrlbarPrefs.clear("quicksuggest.rustEnabled");
465 "uninitQuickSuggest setting rustEnabled and awaiting enablePromise"
467 await lazy.QuickSuggest.rustBackend.enablePromise;
468 this.info?.("uninitQuickSuggest done awaiting enablePromise");
470 if (rustEnabled && !lazy.UrlbarPrefs.get("quicksuggest.rustEnabled")) {
471 this.info?.("uninitQuickSuggest syncing MockRemoteSettings");
472 await this.#mockRemoteSettings.sync();
473 this.info?.("uninitQuickSuggest done syncing MockRemoteSettings");
476 this.#mockRemoteSettings.cleanup();
477 this.#mockRustSuggest.cleanup();
479 if (clearDataCollectionEnabled) {
480 lazy.UrlbarPrefs.clear("quicksuggest.dataCollection.enabled");
483 this.info?.("uninitQuickSuggest done");
487 * Clears the current remote settings data and adds a new set of data.
488 * This can be used to add remote settings data after
489 * `ensureQuickSuggestInit()` has been called.
491 * @param {Array} data
492 * Array of remote settings data objects.
494 async setRemoteSettingsResults(data) {
495 await this.#mockRemoteSettings.update({ data });
496 this.#mockRustSuggest.update({ data });
500 * Sets the quick suggest configuration. You should call this again with
501 * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`.
503 * @param {object} config
504 * The config to be applied. See
506 async setConfig(config) {
507 await this.#mockRemoteSettings.update({ config });
511 * Sets the quick suggest configuration, calls your callback, and restores the
512 * previous configuration.
514 * @param {object} options
515 * The options object.
516 * @param {object} options.config
517 * The configuration that should be used with the callback
518 * @param {Function} options.callback
519 * Will be called with the configuration applied
521 * @see {@link setConfig}
523 async withConfig({ config, callback }) {
524 let original = lazy.QuickSuggest.jsBackend.config;
525 await this.setConfig(config);
527 await this.setConfig(original);
531 * Sets the Firefox Suggest scenario and waits for prefs to be updated.
533 * @param {string} scenario
534 * Pass falsey to reset the scenario to the default.
536 async setScenario(scenario) {
537 // If we try to set the scenario before a previous update has finished,
538 // `updateFirefoxSuggestScenario` will bail, so wait.
539 await this.waitForScenarioUpdated();
540 await lazy.UrlbarPrefs.updateFirefoxSuggestScenario({ scenario });
544 * Waits for any prior scenario update to finish.
546 async waitForScenarioUpdated() {
547 await lazy.TestUtils.waitForCondition(
548 () => !lazy.UrlbarPrefs.updatingFirefoxSuggestScenario,
549 "Waiting for updatingFirefoxSuggestScenario to be false"
554 * Asserts a result is a quick suggest result.
556 * @param {object} [options]
557 * The options object.
558 * @param {string} options.url
559 * The expected URL. At least one of `url` and `originalUrl` must be given.
560 * @param {string} options.originalUrl
561 * The expected original URL (the URL with an unreplaced timestamp
562 * template). At least one of `url` and `originalUrl` must be given.
563 * @param {object} options.window
564 * The window that should be used for this assertion
565 * @param {number} [options.index]
566 * The expected index of the quick suggest result. Pass -1 to use the index
567 * of the last result.
568 * @param {boolean} [options.isSponsored]
569 * Whether the result is expected to be sponsored.
570 * @param {boolean} [options.isBestMatch]
571 * Whether the result is expected to be a best match.
573 * The quick suggest result.
575 async assertIsQuickSuggest({
585 "At least one of url and originalUrl is specified"
589 let resultCount = lazy.UrlbarTestUtils.getResultCount(window);
595 "Sanity check: Result count should be > 1"
598 index = resultCount - 1;
602 "Sanity check: Result count should be > 0"
607 let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
611 let { result } = details;
614 `Checking actual result at index ${index}: ` + JSON.stringify(result)
619 "UrlbarProviderQuickSuggest",
620 "Result provider name is UrlbarProviderQuickSuggest"
622 this.Assert.equal(details.type, lazy.UrlbarUtils.RESULT_TYPE.URL);
623 this.Assert.equal(details.isSponsored, isSponsored, "Result isSponsored");
625 this.Assert.equal(details.url, url, "Result URL");
629 result.payload.originalUrl,
631 "Result original URL"
635 this.Assert.equal(!!result.isBestMatch, isBestMatch, "Result isBestMatch");
637 let { row } = details.element;
639 let sponsoredElement = row._elements.get("description");
640 if (isSponsored || isBestMatch) {
641 this.Assert.ok(sponsoredElement, "Result sponsored label element exists");
643 sponsoredElement.textContent,
644 isSponsored ? "Sponsored" : "",
645 "Result sponsored label"
650 "Result sponsored label element should not exist"
655 result.payload.helpUrl,
656 lazy.QuickSuggest.HELP_URL,
660 if (lazy.UrlbarPrefs.get("resultMenu")) {
662 row._buttons.get("menu"),
663 "The menu button should be present"
666 let helpButton = row._buttons.get("help");
667 this.Assert.ok(helpButton, "The help button should be present");
669 let blockButton = row._buttons.get("block");
673 lazy.UrlbarPrefs.get("quickSuggestBlockingEnabled"),
674 "The block button is present iff quick suggest blocking is enabled"
679 lazy.UrlbarPrefs.get("bestMatchBlockingEnabled"),
680 "The block button is present iff best match blocking is enabled"
689 * Asserts a result is not a quick suggest result.
691 * @param {object} window
692 * The window that should be used for this assertion
693 * @param {number} index
694 * The index of the result.
696 async assertIsNotQuickSuggest(window, index) {
697 let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
701 this.Assert.notEqual(
702 details.result.providerName,
703 "UrlbarProviderQuickSuggest",
704 `Result at index ${index} is not provided by UrlbarProviderQuickSuggest`
709 * Asserts that none of the results are quick suggest results.
711 * @param {object} window
712 * The window that should be used for this assertion
714 async assertNoQuickSuggestResults(window) {
715 for (let i = 0; i < lazy.UrlbarTestUtils.getResultCount(window); i++) {
716 await this.assertIsNotQuickSuggest(window, i);
721 * Checks the values of all the quick suggest telemetry keyed scalars and,
722 * if provided, other non-quick-suggest keyed scalars. Scalar values are all
725 * @param {object} expectedKeysByScalarName
726 * Maps scalar names to keys that are expected to be recorded. The value for
727 * each key is assumed to be 1. If you expect a scalar to be incremented,
728 * include it in this object; otherwise, don't include it.
730 assertScalars(expectedKeysByScalarName) {
731 let scalars = lazy.TelemetryTestUtils.getProcessScalars(
737 // Check all quick suggest scalars.
738 expectedKeysByScalarName = { ...expectedKeysByScalarName };
739 for (let scalarName of Object.values(
740 lazy.UrlbarProviderQuickSuggest.TELEMETRY_SCALARS
742 if (scalarName in expectedKeysByScalarName) {
743 lazy.TelemetryTestUtils.assertKeyedScalar(
746 expectedKeysByScalarName[scalarName],
749 delete expectedKeysByScalarName[scalarName];
752 !(scalarName in scalars),
753 "Scalar should not be present: " + scalarName
758 // Check any other remaining scalars that were passed in.
759 for (let [scalarName, key] of Object.entries(expectedKeysByScalarName)) {
760 lazy.TelemetryTestUtils.assertKeyedScalar(scalars, scalarName, key, 1);
765 * Checks quick suggest telemetry events. This is the same as
766 * `TelemetryTestUtils.assertEvents()` except it filters in only quick suggest
767 * events by default. If you are expecting events that are not in the quick
768 * suggest category, use `TelemetryTestUtils.assertEvents()` directly or pass
769 * in a filter override for `category`.
771 * @param {Array} expectedEvents
772 * List of expected telemetry events.
773 * @param {object} filterOverrides
774 * Extra properties to set in the filter object.
775 * @param {object} options
776 * The options object to pass to `TelemetryTestUtils.assertEvents()`.
778 assertEvents(expectedEvents, filterOverrides = {}, options = undefined) {
779 lazy.TelemetryTestUtils.assertEvents(
782 category: lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
790 * Creates a `sinon.sandbox` and `sinon.spy` that can be used to instrument
791 * the quick suggest custom telemetry pings. If `init` was called with a test
792 * scope where `registerCleanupFunction` is defined, the sandbox will
793 * automically be restored at the end of the test.
796 * An object: { sandbox, spy, spyCleanup }
797 * `spyCleanup` is a cleanup function that should be called if you're in a
798 * browser chrome test and you did not also call `init`, or if you need to
799 * remove the spy before the test ends for some other reason. You can ignore
802 createTelemetryPingSpy() {
803 let sandbox = lazy.sinon.createSandbox();
804 let spy = sandbox.spy(
805 PartnerLinkAttribution._pingCentre,
806 "sendStructuredIngestionPing"
808 let spyCleanup = () => sandbox.restore();
809 this.registerCleanupFunction?.(spyCleanup);
810 return { sandbox, spy, spyCleanup };
814 * Asserts that custom telemetry pings are recorded in the order they appear
815 * in the given `pings` array and that no other pings are recorded.
817 * @param {object} spy
818 * A `sinon.spy` object. See `createTelemetryPingSpy()`. This method resets
819 * the spy before returning.
820 * @param {Array} pings
821 * The expected pings in the order they are expected to be recorded. Each
822 * item in this array should be an object: `{ type, payload }`
825 * The ping's expected type, one of the `CONTEXTUAL_SERVICES_PING_TYPES`
828 * The ping's expected payload. For convenience, you can leave out
829 * properties whose values are expected to be the default values defined
830 * in `DEFAULT_PING_PAYLOADS`.
832 assertPings(spy, pings) {
833 let calls = spy.getCalls();
837 "Expected number of ping calls"
840 for (let i = 0; i < pings.length; i++) {
843 `Checking ping at index ${i}, expected is: ` + JSON.stringify(ping)
846 // Add default properties to the expected payload for any that aren't
848 let { type, payload } = ping;
849 let defaultPayload = DEFAULT_PING_PAYLOADS[type];
852 `Sanity check: Default payload exists for type: ${type}`
854 payload = { ...defaultPayload, ...payload };
856 // Check the endpoint URL.
858 let endpointURL = call.args[1];
860 endpointURL.includes(type),
861 `Endpoint URL corresponds to the expected ping type: ${type}`
864 // Check the payload.
865 let actualPayload = call.args[0];
866 this._assertPingPayload(actualPayload, payload);
873 * Helper for checking contextual services ping payloads.
875 * @param {object} actualPayload
876 * The actual payload in the ping.
877 * @param {object} expectedPayload
878 * An object describing the expected payload. Non-function values in this
879 * object are checked for equality against the corresponding actual payload
880 * values. Function values are called and passed the corresponding actual
881 * values and should return true if the actual values are correct.
883 _assertPingPayload(actualPayload, expectedPayload) {
885 "Checking ping payload. Actual: " +
886 JSON.stringify(actualPayload) +
887 " -- Expected (excluding function properties): " +
888 JSON.stringify(expectedPayload)
892 Object.entries(actualPayload).length,
893 Object.entries(expectedPayload).length,
894 "Payload has expected number of properties"
897 for (let [key, expectedValue] of Object.entries(expectedPayload)) {
898 let actualValue = actualPayload[key];
899 if (typeof expectedValue == "function") {
900 this.Assert.ok(expectedValue(actualValue), "Payload property: " + key);
905 "Payload property: " + key
912 * Asserts that URLs in a result's payload have the timestamp template
913 * substring replaced with real timestamps.
915 * @param {UrlbarResult} result The results to check
916 * @param {object} urls
917 * An object that contains the expected payload properties with template
918 * substrings. For example:
921 * url: "http://example.com/foo-%YYYYMMDDHH%",
922 * sponsoredClickUrl: "http://example.com/bar-%YYYYMMDDHH%",
926 assertTimestampsReplaced(result, urls) {
927 let { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = lazy.QuickSuggest;
929 // Parse the timestamp strings from each payload property and save them in
930 // `urls[key].timestamp`.
932 for (let [key, url] of Object.entries(urls)) {
933 let index = url.indexOf(TIMESTAMP_TEMPLATE);
936 `Timestamp template ${TIMESTAMP_TEMPLATE} is in URL ${url} for key ${key}`
938 let value = result.payload[key];
939 this.Assert.ok(value, "Key is in result payload: " + key);
940 let timestamp = value.substring(index, index + TIMESTAMP_LENGTH);
942 // Set `urls[key]` to an object that's helpful in the logged info message
944 urls[key] = { url, value, timestamp };
947 this.info?.("Parsed timestamps: " + JSON.stringify(urls));
949 // Make a set of unique timestamp strings. There should only be one.
950 let { timestamp } = Object.values(urls)[0];
951 this.Assert.deepEqual(
952 [...new Set(Object.values(urls).map(o => o.timestamp))],
954 "There's only one unique timestamp string"
957 // Parse the parts of the timestamp string.
958 let year = timestamp.slice(0, -6);
959 let month = timestamp.slice(-6, -4);
960 let day = timestamp.slice(-4, -2);
961 let hour = timestamp.slice(-2);
962 let date = new Date(year, month - 1, day, hour);
964 // The timestamp should be no more than two hours in the past. Typically it
965 // will be the same as the current hour, but since its resolution is in
966 // terms of hours and it's possible the test may have crossed over into a
967 // new hour as it was running, allow for the previous hour.
969 Date.now() - 2 * 60 * 60 * 1000,
971 "Timestamp is within the past two hours"
976 * Calls a callback while enrolled in a mock Nimbus experiment. The experiment
977 * is automatically unenrolled and cleaned up after the callback returns.
979 * @param {object} options
980 * Options for the mock experiment.
981 * @param {Function} options.callback
982 * The callback to call while enrolled in the mock experiment.
983 * @param {object} options.options
984 * See {@link enrollExperiment}.
986 async withExperiment({ callback, ...options }) {
987 let doExperimentCleanup = await this.enrollExperiment(options);
989 await doExperimentCleanup();
993 * Enrolls in a mock Nimbus experiment.
995 * @param {object} options
996 * Options for the mock experiment.
997 * @param {object} [options.valueOverrides]
998 * Values for feature variables.
999 * @returns {Promise<Function>}
1000 * The experiment cleanup function (async).
1002 async enrollExperiment({ valueOverrides = {} }) {
1003 this.info?.("Awaiting ExperimentAPI.ready");
1004 await lazy.ExperimentAPI.ready();
1006 // Wait for any prior scenario updates to finish. If updates are ongoing,
1007 // UrlbarPrefs will ignore the Nimbus update when the experiment is
1008 // installed. This shouldn't be a problem in practice because in reality
1009 // scenario updates are triggered only on app startup and Nimbus
1010 // enrollments, but tests can trigger lots of updates back to back.
1011 await this.waitForScenarioUpdated();
1013 let doExperimentCleanup =
1014 await lazy.ExperimentFakes.enrollWithFeatureConfig({
1016 featureId: "urlbar",
1017 value: valueOverrides,
1020 // Wait for the pref updates triggered by the experiment enrollment.
1021 this.info?.("Awaiting update after enrolling in experiment");
1022 await this.waitForScenarioUpdated();
1024 return async () => {
1025 this.info?.("Awaiting experiment cleanup");
1026 await doExperimentCleanup();
1028 // The same pref updates will be triggered by unenrollment, so wait for
1030 this.info?.("Awaiting update after unenrolling in experiment");
1031 await this.waitForScenarioUpdated();
1036 * Clears the Nimbus exposure event.
1038 async clearExposureEvent() {
1039 // Exposure event recording is queued to the idle thread, so wait for idle
1040 // before we start so any events from previous tasks will have been recorded
1041 // and won't interfere with this task.
1042 await new Promise(resolve => Services.tm.idleDispatchToMainThread(resolve));
1044 Services.telemetry.clearEvents();
1045 lazy.NimbusFeatures.urlbar._didSendExposureEvent = false;
1046 lazy.QuickSuggest._recordedExposureEvent = false;
1050 * Asserts the Nimbus exposure event is recorded or not as expected.
1052 * @param {boolean} expectedRecorded
1053 * Whether the event is expected to be recorded.
1055 async assertExposureEvent(expectedRecorded) {
1057 lazy.QuickSuggest._recordedExposureEvent,
1059 "_recordedExposureEvent is correct"
1063 category: "normandy",
1065 object: "nimbus_experiment",
1068 let expectedEvents = [];
1069 if (expectedRecorded) {
1070 expectedEvents.push({
1073 branchSlug: "control",
1074 featureId: "urlbar",
1079 // The event recording is queued to the idle thread when the search starts,
1080 // so likewise queue the assert to idle instead of doing it immediately.
1081 await new Promise(resolve => {
1082 Services.tm.idleDispatchToMainThread(() => {
1083 lazy.TelemetryTestUtils.assertEvents(expectedEvents, filter);
1090 * Sets the app's locales, calls your callback, and resets locales.
1092 * @param {Array} locales
1093 * An array of locale strings. The entire array will be set as the available
1094 * locales, and the first locale in the array will be set as the requested
1096 * @param {Function} callback
1097 * The callback to be called with the {@link locales} set. This function can
1100 async withLocales(locales, callback) {
1101 let promiseChanges = async desiredLocales => {
1103 "Changing locales from " +
1104 JSON.stringify(Services.locale.requestedLocales) +
1106 JSON.stringify(desiredLocales)
1109 if (desiredLocales[0] == Services.locale.requestedLocales[0]) {
1110 // Nothing happens when the locale doesn't actually change.
1114 this.info?.("Waiting for intl:requested-locales-changed");
1115 await lazy.TestUtils.topicObserved("intl:requested-locales-changed");
1116 this.info?.("Observed intl:requested-locales-changed");
1118 // Wait for the search service to reload engines. Otherwise tests can fail
1119 // in strange ways due to internal search service state during shutdown.
1120 // It won't always reload engines but it's hard to tell in advance when it
1121 // won't, so also set a timeout.
1122 this.info?.("Waiting for TOPIC_SEARCH_SERVICE");
1123 await Promise.race([
1124 lazy.TestUtils.topicObserved(
1125 lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
1126 (subject, data) => {
1127 this.info?.("Observed TOPIC_SEARCH_SERVICE with data: " + data);
1128 return data == "engines-reloaded";
1131 new Promise(resolve => {
1132 lazy.setTimeout(() => {
1133 this.info?.("Timed out waiting for TOPIC_SEARCH_SERVICE");
1139 this.info?.("Done waiting for locale changes");
1142 let available = Services.locale.availableLocales;
1143 let requested = Services.locale.requestedLocales;
1145 let newRequested = locales.slice(0, 1);
1146 let promise = promiseChanges(newRequested);
1147 Services.locale.availableLocales = locales;
1148 Services.locale.requestedLocales = newRequested;
1152 Services.locale.appLocaleAsBCP47,
1154 "App locale is now " + locales[0]
1159 promise = promiseChanges(requested);
1160 Services.locale.availableLocales = available;
1161 Services.locale.requestedLocales = requested;
1165 #mockRemoteSettings = null;
1166 #mockRustSuggest = null;
1169 export var QuickSuggestTestUtils = new _QuickSuggestTestUtils();