Bug 1856736: Revert group labels and result labels to their previous appearance r=adw
[gecko.git] / browser / components / urlbar / tests / quicksuggest / QuickSuggestTestUtils.sys.mjs
blob378199416c68cde7dca9fb5d82fdfee1213f42d6
1 /* Any copyright is dedicated to the Public Domain.
2    http://creativecommons.org/publicdomain/zero/1.0/ */
4 /* eslint-disable mozilla/valid-lazy */
6 import {
7   CONTEXTUAL_SERVICES_PING_TYPES,
8   PartnerLinkAttribution,
9 } from "resource:///modules/PartnerLinkAttribution.sys.mjs";
11 const lazy = {};
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",
31 });
33 let gTestScope;
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
39 // xpcshell tests.
40 Object.defineProperty(lazy, "UrlbarTestUtils", {
41   get: () => {
42     if (!lazy._UrlbarTestUtils) {
43       const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
44         "resource://testing-common/UrlbarTestUtils.sys.mjs"
45       );
46       module.init(gTestScope);
47       gTestScope.registerCleanupFunction(() => {
48         // Make sure the utils are re-initialized during the next test.
49         lazy._UrlbarTestUtils = null;
50       });
51       lazy._UrlbarTestUtils = module;
52     }
53     return lazy._UrlbarTestUtils;
54   },
55 });
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
61 // xpcshell tests.
62 Object.defineProperty(lazy, "MerinoTestUtils", {
63   get: () => {
64     if (!lazy._MerinoTestUtils) {
65       const { MerinoTestUtils: module } = ChromeUtils.importESModule(
66         "resource://testing-common/MerinoTestUtils.sys.mjs"
67       );
68       module.init(gTestScope);
69       gTestScope.registerCleanupFunction(() => {
70         // Make sure the utils are re-initialized during the next test.
71         lazy._MerinoTestUtils = null;
72       });
73       lazy._MerinoTestUtils = module;
74     }
75     return lazy._MerinoTestUtils;
76   },
77 });
79 const DEFAULT_CONFIG = {};
81 const BEST_MATCH_CONFIG = {
82   best_match: {
83     blocked_suggestion_ids: [],
84     min_search_string_length: 4,
85   },
88 const DEFAULT_PING_PAYLOADS = {
89   [CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK]: {
90     advertiser: "testadvertiser",
91     block_id: 1,
92     context_id: () => actual => !!actual,
93     iab_category: "22 - Shopping",
94     improve_suggest_experience_checked: false,
95     match_type: "firefox-suggest",
96     request_id: null,
97     source: "remote-settings",
98   },
99   [CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION]: {
100     advertiser: "testadvertiser",
101     block_id: 1,
102     context_id: () => actual => !!actual,
103     improve_suggest_experience_checked: false,
104     match_type: "firefox-suggest",
105     reporting_url: "https://example.com/click",
106     request_id: null,
107     source: "remote-settings",
108   },
109   [CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION]: {
110     advertiser: "testadvertiser",
111     block_id: 1,
112     context_id: () => actual => !!actual,
113     improve_suggest_experience_checked: false,
114     is_clicked: false,
115     match_type: "firefox-suggest",
116     reporting_url: "https://example.com/impression",
117     request_id: null,
118     source: "remote-settings",
119   },
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 = [
127   "Assert",
128   "EventUtils",
129   "info",
130   "registerCleanupFunction",
134  * Mock RemoteSettings.
136  * @param {object} options
137  *   Options object
138  * @param {object} options.config
139  *   Dummy config in the RemoteSettings.
140  * @param {Array} options.data
141  *   Dummy data in the RemoteSettings.
142  */
143 class MockRemoteSettings {
144   constructor({ config = DEFAULT_CONFIG, data = [] }) {
145     this.#config = config;
146     this.#data = data;
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);
155     });
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");
161       }
162       const encoder = new TextEncoder();
163       return {
164         buffer: encoder.encode(JSON.stringify(record.attachment)),
165       };
166     });
167   }
169   async sync() {
170     if (!lazy.QuickSuggest.jsBackend.rs) {
171       // There are no registered features that use remote settings.
172       return;
173     }
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);
178     });
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);
189             stub.restore();
190             resolve();
191           });
192       });
193     });
195     // Force to sync.
196     const rs = lazy.RemoteSettings("quicksuggest");
197     rs.emit("sync");
199     // Wait for sync.
200     await Promise.all([onConfigSync, ...onFeatureSyncs]);
201   }
203   /*
204    * Update the config and data in RemoteSettings. If the config or the data are
205    * undefined, use the current one.
206    *
207    * @param {object} options
208    *   Options object
209    * @param {object} options.config
210    *   Dummy config in the RemoteSettings.
211    * @param {Array} options.data
212    *   Dummy data in the RemoteSettings.
213    */
214   async update({ config = this.#config, data = this.#data }) {
215     this.#config = config;
216     this.#data = data;
218     await this.sync();
219   }
221   cleanup() {
222     this.#sandbox.restore();
223   }
225   #config = null;
226   #data = null;
227   #sandbox = null;
231  * Mock `RustSuggest` implementation.
233  * @param {object} options
234  *   Options object
235  * @param {Array} options.data
236  *   Mock remote settings records.
237  */
238 class MockRustSuggest {
239   constructor({ data = [] }) {
240     this.#data = data;
242     this.#sandbox = lazy.sinon.createSandbox();
243     this.#sandbox.stub(lazy.SuggestStore, "init").returns(this);
244   }
246   /**
247    * Updates the mock data.
248    *
249    * @param {object} options
250    *   Options object
251    * @param {Array} options.data
252    *   Mock remote settings records.
253    */
254   async update({ data }) {
255     this.#data = data;
256   }
258   cleanup() {
259     this.#sandbox.restore();
260   }
262   // `RustSuggest` methods below.
264   ingest() {
265     return Promise.resolve();
266   }
268   interrupt() {}
270   clear() {}
272   async query(query) {
273     let records = this.#data.filter(record => record.type == "data");
274     let suggestions = records
275       .map(record => record.attachment)
276       .flat()
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";
284       if (
285         (isSponsored && query.includeSponsored) ||
286         (!isSponsored && query.includeNonSponsored)
287       ) {
288         matchedSuggestions.push(
289           isSponsored
290             ? new lazy.Suggestion.Amp(
291                 suggestion.title,
292                 suggestion.url,
293                 null, // icon
294                 query.keyword, // fullKeyword
295                 suggestion.id, // blockId
296                 suggestion.advertiser,
297                 suggestion.iab_category,
298                 suggestion.impression_url,
299                 suggestion.click_url
300               )
301             : new lazy.Suggestion.Wikipedia(
302                 suggestion.title,
303                 suggestion.url,
304                 null, // icon
305                 query.keyword // fullKeyword
306               )
307         );
308       }
309     }
310     return matchedSuggestions;
311   }
313   #data = null;
314   #sandbox = null;
318  * Test utils for quick suggest.
319  */
320 class _QuickSuggestTestUtils {
321   /**
322    * Initializes the utils.
323    *
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.
327    */
328   init(scope) {
329     if (!scope) {
330       throw new Error("QuickSuggestTestUtils() must be called with a scope");
331     }
332     gTestScope = scope;
333     for (let p of TEST_SCOPE_PROPERTIES) {
334       this[p] = scope[p];
335     }
336     // If you add other properties to `this`, null them in `uninit()`.
338     Services.telemetry.clearScalars();
340     scope.registerCleanupFunction?.(() => this.uninit());
341   }
343   /**
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.
348    */
349   uninit() {
350     gTestScope = null;
351     for (let p of TEST_SCOPE_PROPERTIES) {
352       this[p] = null;
353     }
354     Services.telemetry.clearScalars();
355   }
357   get DEFAULT_CONFIG() {
358     // Return a clone so callers can modify it.
359     return Cu.cloneInto(DEFAULT_CONFIG, this);
360   }
362   get BEST_MATCH_CONFIG() {
363     // Return a clone so callers can modify it.
364     return Cu.cloneInto(BEST_MATCH_CONFIG, this);
365   }
367   /**
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
370    * suggestions.
371    *
372    * @param {object} options
373    *   Options object
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.
396    */
397   async ensureQuickSuggestInit({
398     remoteSettingsResults,
399     merinoSuggestions = null,
400     config = DEFAULT_CONFIG,
401     rustEnabled = false,
402   } = {}) {
403     this.#mockRemoteSettings = new MockRemoteSettings({
404       config,
405       data: remoteSettingsResults,
406     });
407     this.#mockRustSuggest = new MockRustSuggest({
408       data: remoteSettingsResults,
409     });
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.
418     this.info?.(
419       "ensureQuickSuggestInit setting rustEnabled and awaiting enablePromise"
420     );
421     lazy.UrlbarPrefs.set("quicksuggest.rustEnabled", rustEnabled);
422     await lazy.QuickSuggest.rustBackend.enablePromise;
423     this.info?.("ensureQuickSuggestInit done awaiting enablePromise");
425     if (!rustEnabled) {
426       // Sync with current data.
427       this.info?.("ensureQuickSuggestInit syncing MockRemoteSettings");
428       await this.#mockRemoteSettings.sync();
429       this.info?.("ensureQuickSuggestInit done syncing MockRemoteSettings");
430     }
432     // Set up Merino.
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");
439     }
441     let cleanupCalled = false;
442     let cleanup = async () => {
443       if (!cleanupCalled) {
444         cleanupCalled = true;
445         await this.#uninitQuickSuggest(!!merinoSuggestions);
446       }
447     };
448     this.registerCleanupFunction?.(cleanup);
450     return cleanup;
451   }
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");
464     this.info?.(
465       "uninitQuickSuggest setting rustEnabled and awaiting enablePromise"
466     );
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");
474     }
476     this.#mockRemoteSettings.cleanup();
477     this.#mockRustSuggest.cleanup();
479     if (clearDataCollectionEnabled) {
480       lazy.UrlbarPrefs.clear("quicksuggest.dataCollection.enabled");
481     }
483     this.info?.("uninitQuickSuggest done");
484   }
486   /**
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.
490    *
491    * @param {Array} data
492    *   Array of remote settings data objects.
493    */
494   async setRemoteSettingsResults(data) {
495     await this.#mockRemoteSettings.update({ data });
496     this.#mockRustSuggest.update({ data });
497   }
499   /**
500    * Sets the quick suggest configuration. You should call this again with
501    * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`.
502    *
503    * @param {object} config
504    *   The config to be applied. See
505    */
506   async setConfig(config) {
507     await this.#mockRemoteSettings.update({ config });
508   }
510   /**
511    * Sets the quick suggest configuration, calls your callback, and restores the
512    * previous configuration.
513    *
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
520    *
521    * @see {@link setConfig}
522    */
523   async withConfig({ config, callback }) {
524     let original = lazy.QuickSuggest.jsBackend.config;
525     await this.setConfig(config);
526     await callback();
527     await this.setConfig(original);
528   }
530   /**
531    * Sets the Firefox Suggest scenario and waits for prefs to be updated.
532    *
533    * @param {string} scenario
534    *   Pass falsey to reset the scenario to the default.
535    */
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 });
541   }
543   /**
544    * Waits for any prior scenario update to finish.
545    */
546   async waitForScenarioUpdated() {
547     await lazy.TestUtils.waitForCondition(
548       () => !lazy.UrlbarPrefs.updatingFirefoxSuggestScenario,
549       "Waiting for updatingFirefoxSuggestScenario to be false"
550     );
551   }
553   /**
554    * Asserts a result is a quick suggest result.
555    *
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.
572    * @returns {result}
573    *   The quick suggest result.
574    */
575   async assertIsQuickSuggest({
576     url,
577     originalUrl,
578     window,
579     index = -1,
580     isSponsored = true,
581     isBestMatch = false,
582   } = {}) {
583     this.Assert.ok(
584       url || originalUrl,
585       "At least one of url and originalUrl is specified"
586     );
588     if (index < 0) {
589       let resultCount = lazy.UrlbarTestUtils.getResultCount(window);
590       if (isBestMatch) {
591         index = 1;
592         this.Assert.greater(
593           resultCount,
594           1,
595           "Sanity check: Result count should be > 1"
596         );
597       } else {
598         index = resultCount - 1;
599         this.Assert.greater(
600           resultCount,
601           0,
602           "Sanity check: Result count should be > 0"
603         );
604       }
605     }
607     let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
608       window,
609       index
610     );
611     let { result } = details;
613     this.info?.(
614       `Checking actual result at index ${index}: ` + JSON.stringify(result)
615     );
617     this.Assert.equal(
618       result.providerName,
619       "UrlbarProviderQuickSuggest",
620       "Result provider name is UrlbarProviderQuickSuggest"
621     );
622     this.Assert.equal(details.type, lazy.UrlbarUtils.RESULT_TYPE.URL);
623     this.Assert.equal(details.isSponsored, isSponsored, "Result isSponsored");
624     if (url) {
625       this.Assert.equal(details.url, url, "Result URL");
626     }
627     if (originalUrl) {
628       this.Assert.equal(
629         result.payload.originalUrl,
630         originalUrl,
631         "Result original URL"
632       );
633     }
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");
642       this.Assert.equal(
643         sponsoredElement.textContent,
644         isSponsored ? "Sponsored" : "",
645         "Result sponsored label"
646       );
647     } else {
648       this.Assert.ok(
649         !sponsoredElement,
650         "Result sponsored label element should not exist"
651       );
652     }
654     this.Assert.equal(
655       result.payload.helpUrl,
656       lazy.QuickSuggest.HELP_URL,
657       "Result helpURL"
658     );
660     if (lazy.UrlbarPrefs.get("resultMenu")) {
661       this.Assert.ok(
662         row._buttons.get("menu"),
663         "The menu button should be present"
664       );
665     } else {
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");
670       if (!isBestMatch) {
671         this.Assert.equal(
672           !!blockButton,
673           lazy.UrlbarPrefs.get("quickSuggestBlockingEnabled"),
674           "The block button is present iff quick suggest blocking is enabled"
675         );
676       } else {
677         this.Assert.equal(
678           !!blockButton,
679           lazy.UrlbarPrefs.get("bestMatchBlockingEnabled"),
680           "The block button is present iff best match blocking is enabled"
681         );
682       }
683     }
685     return details;
686   }
688   /**
689    * Asserts a result is not a quick suggest result.
690    *
691    * @param {object} window
692    *   The window that should be used for this assertion
693    * @param {number} index
694    *   The index of the result.
695    */
696   async assertIsNotQuickSuggest(window, index) {
697     let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
698       window,
699       index
700     );
701     this.Assert.notEqual(
702       details.result.providerName,
703       "UrlbarProviderQuickSuggest",
704       `Result at index ${index} is not provided by UrlbarProviderQuickSuggest`
705     );
706   }
708   /**
709    * Asserts that none of the results are quick suggest results.
710    *
711    * @param {object} window
712    *   The window that should be used for this assertion
713    */
714   async assertNoQuickSuggestResults(window) {
715     for (let i = 0; i < lazy.UrlbarTestUtils.getResultCount(window); i++) {
716       await this.assertIsNotQuickSuggest(window, i);
717     }
718   }
720   /**
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
723    * assumed to be 1.
724    *
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.
729    */
730   assertScalars(expectedKeysByScalarName) {
731     let scalars = lazy.TelemetryTestUtils.getProcessScalars(
732       "parent",
733       true,
734       true
735     );
737     // Check all quick suggest scalars.
738     expectedKeysByScalarName = { ...expectedKeysByScalarName };
739     for (let scalarName of Object.values(
740       lazy.UrlbarProviderQuickSuggest.TELEMETRY_SCALARS
741     )) {
742       if (scalarName in expectedKeysByScalarName) {
743         lazy.TelemetryTestUtils.assertKeyedScalar(
744           scalars,
745           scalarName,
746           expectedKeysByScalarName[scalarName],
747           1
748         );
749         delete expectedKeysByScalarName[scalarName];
750       } else {
751         this.Assert.ok(
752           !(scalarName in scalars),
753           "Scalar should not be present: " + scalarName
754         );
755       }
756     }
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);
761     }
762   }
764   /**
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`.
770    *
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()`.
777    */
778   assertEvents(expectedEvents, filterOverrides = {}, options = undefined) {
779     lazy.TelemetryTestUtils.assertEvents(
780       expectedEvents,
781       {
782         category: lazy.QuickSuggest.TELEMETRY_EVENT_CATEGORY,
783         ...filterOverrides,
784       },
785       options
786     );
787   }
789   /**
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.
794    *
795    * @returns {object}
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
800    *   it otherwise.
801    */
802   createTelemetryPingSpy() {
803     let sandbox = lazy.sinon.createSandbox();
804     let spy = sandbox.spy(
805       PartnerLinkAttribution._pingCentre,
806       "sendStructuredIngestionPing"
807     );
808     let spyCleanup = () => sandbox.restore();
809     this.registerCleanupFunction?.(spyCleanup);
810     return { sandbox, spy, spyCleanup };
811   }
813   /**
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.
816    *
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 }`
823    *
824    *   {string} type
825    *     The ping's expected type, one of the `CONTEXTUAL_SERVICES_PING_TYPES`
826    *     values.
827    *   {object} payload
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`.
831    */
832   assertPings(spy, pings) {
833     let calls = spy.getCalls();
834     this.Assert.equal(
835       calls.length,
836       pings.length,
837       "Expected number of ping calls"
838     );
840     for (let i = 0; i < pings.length; i++) {
841       let ping = pings[i];
842       this.info?.(
843         `Checking ping at index ${i}, expected is: ` + JSON.stringify(ping)
844       );
846       // Add default properties to the expected payload for any that aren't
847       // already defined.
848       let { type, payload } = ping;
849       let defaultPayload = DEFAULT_PING_PAYLOADS[type];
850       this.Assert.ok(
851         defaultPayload,
852         `Sanity check: Default payload exists for type: ${type}`
853       );
854       payload = { ...defaultPayload, ...payload };
856       // Check the endpoint URL.
857       let call = calls[i];
858       let endpointURL = call.args[1];
859       this.Assert.ok(
860         endpointURL.includes(type),
861         `Endpoint URL corresponds to the expected ping type: ${type}`
862       );
864       // Check the payload.
865       let actualPayload = call.args[0];
866       this._assertPingPayload(actualPayload, payload);
867     }
869     spy.resetHistory();
870   }
872   /**
873    * Helper for checking contextual services ping payloads.
874    *
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.
882    */
883   _assertPingPayload(actualPayload, expectedPayload) {
884     this.info?.(
885       "Checking ping payload. Actual: " +
886         JSON.stringify(actualPayload) +
887         " -- Expected (excluding function properties): " +
888         JSON.stringify(expectedPayload)
889     );
891     this.Assert.equal(
892       Object.entries(actualPayload).length,
893       Object.entries(expectedPayload).length,
894       "Payload has expected number of properties"
895     );
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);
901       } else {
902         this.Assert.equal(
903           actualValue,
904           expectedValue,
905           "Payload property: " + key
906         );
907       }
908     }
909   }
911   /**
912    * Asserts that URLs in a result's payload have the timestamp template
913    * substring replaced with real timestamps.
914    *
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:
919    *   ```js
920    *   {
921    *     url: "http://example.com/foo-%YYYYMMDDHH%",
922    *     sponsoredClickUrl: "http://example.com/bar-%YYYYMMDDHH%",
923    *   }
924    *   ```
925    */
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`.
931     urls = { ...urls };
932     for (let [key, url] of Object.entries(urls)) {
933       let index = url.indexOf(TIMESTAMP_TEMPLATE);
934       this.Assert.ok(
935         index >= 0,
936         `Timestamp template ${TIMESTAMP_TEMPLATE} is in URL ${url} for key ${key}`
937       );
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
943       // below.
944       urls[key] = { url, value, timestamp };
945     }
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))],
953       [timestamp],
954       "There's only one unique timestamp string"
955     );
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.
968     this.Assert.less(
969       Date.now() - 2 * 60 * 60 * 1000,
970       date.getTime(),
971       "Timestamp is within the past two hours"
972     );
973   }
975   /**
976    * Calls a callback while enrolled in a mock Nimbus experiment. The experiment
977    * is automatically unenrolled and cleaned up after the callback returns.
978    *
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}.
985    */
986   async withExperiment({ callback, ...options }) {
987     let doExperimentCleanup = await this.enrollExperiment(options);
988     await callback();
989     await doExperimentCleanup();
990   }
992   /**
993    * Enrolls in a mock Nimbus experiment.
994    *
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).
1001    */
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({
1015         enabled: true,
1016         featureId: "urlbar",
1017         value: valueOverrides,
1018       });
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
1029       // them again.
1030       this.info?.("Awaiting update after unenrolling in experiment");
1031       await this.waitForScenarioUpdated();
1032     };
1033   }
1035   /**
1036    * Clears the Nimbus exposure event.
1037    */
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;
1047   }
1049   /**
1050    * Asserts the Nimbus exposure event is recorded or not as expected.
1051    *
1052    * @param {boolean} expectedRecorded
1053    *   Whether the event is expected to be recorded.
1054    */
1055   async assertExposureEvent(expectedRecorded) {
1056     this.Assert.equal(
1057       lazy.QuickSuggest._recordedExposureEvent,
1058       expectedRecorded,
1059       "_recordedExposureEvent is correct"
1060     );
1062     let filter = {
1063       category: "normandy",
1064       method: "expose",
1065       object: "nimbus_experiment",
1066     };
1068     let expectedEvents = [];
1069     if (expectedRecorded) {
1070       expectedEvents.push({
1071         ...filter,
1072         extra: {
1073           branchSlug: "control",
1074           featureId: "urlbar",
1075         },
1076       });
1077     }
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);
1084         resolve();
1085       });
1086     });
1087   }
1089   /**
1090    * Sets the app's locales, calls your callback, and resets locales.
1091    *
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
1095    *   locale.
1096    * @param {Function} callback
1097    *  The callback to be called with the {@link locales} set. This function can
1098    *  be async.
1099    */
1100   async withLocales(locales, callback) {
1101     let promiseChanges = async desiredLocales => {
1102       this.info?.(
1103         "Changing locales from " +
1104           JSON.stringify(Services.locale.requestedLocales) +
1105           " to " +
1106           JSON.stringify(desiredLocales)
1107       );
1109       if (desiredLocales[0] == Services.locale.requestedLocales[0]) {
1110         // Nothing happens when the locale doesn't actually change.
1111         return;
1112       }
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";
1129           }
1130         ),
1131         new Promise(resolve => {
1132           lazy.setTimeout(() => {
1133             this.info?.("Timed out waiting for TOPIC_SEARCH_SERVICE");
1134             resolve();
1135           }, 2000);
1136         }),
1137       ]);
1139       this.info?.("Done waiting for locale changes");
1140     };
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;
1149     await promise;
1151     this.Assert.equal(
1152       Services.locale.appLocaleAsBCP47,
1153       locales[0],
1154       "App locale is now " + locales[0]
1155     );
1157     await callback();
1159     promise = promiseChanges(requested);
1160     Services.locale.availableLocales = available;
1161     Services.locale.requestedLocales = requested;
1162     await promise;
1163   }
1165   #mockRemoteSettings = null;
1166   #mockRustSuggest = null;
1169 export var QuickSuggestTestUtils = new _QuickSuggestTestUtils();