tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

QuickSuggestTestUtils.sys.mjs (50044B)


      1 /* Any copyright is dedicated to the Public Domain.
      2   http://creativecommons.org/publicdomain/zero/1.0/ */
      3 
      4 /* eslint-disable jsdoc/require-param */
      5 
      6 const lazy = {};
      7 
      8 ChromeUtils.defineESModuleGetters(lazy, {
      9  AmpSuggestions:
     10    "moz-src:///browser/components/urlbar/private/AmpSuggestions.sys.mjs",
     11  ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
     12  NimbusTestUtils: "resource://testing-common/NimbusTestUtils.sys.mjs",
     13  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     14  Region: "resource://gre/modules/Region.sys.mjs",
     15  RemoteSettingsServer:
     16    "resource://testing-common/RemoteSettingsServer.sys.mjs",
     17  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
     18  SharedRemoteSettingsService:
     19    "resource://gre/modules/RustSharedRemoteSettingsService.sys.mjs",
     20  Suggestion:
     21    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     22  TestUtils: "resource://testing-common/TestUtils.sys.mjs",
     23  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     24  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
     25  YelpSubjectType:
     26    "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs",
     27  setTimeout: "resource://gre/modules/Timer.sys.mjs",
     28 });
     29 
     30 /**
     31 * @import {Assert} from "resource://testing-common/Assert.sys.mjs"
     32 */
     33 
     34 let gTestScope;
     35 
     36 // Test utils singletons need special handling. Since they are uninitialized in
     37 // cleanup functions, they must be re-initialized on each new test. That does
     38 // not happen automatically inside system modules like this one because system
     39 // module lifetimes are the app's lifetime, unlike individual browser chrome and
     40 // xpcshell tests.
     41 Object.defineProperty(lazy, "UrlbarTestUtils", {
     42  get: () => {
     43    // eslint-disable-next-line mozilla/valid-lazy
     44    if (!lazy._UrlbarTestUtils) {
     45      const { UrlbarTestUtils: module } = ChromeUtils.importESModule(
     46        "resource://testing-common/UrlbarTestUtils.sys.mjs"
     47      );
     48      module.init(gTestScope);
     49      gTestScope.registerCleanupFunction(() => {
     50        // Make sure the utils are re-initialized during the next test.
     51        // eslint-disable-next-line mozilla/valid-lazy
     52        lazy._UrlbarTestUtils = null;
     53      });
     54      // eslint-disable-next-line mozilla/valid-lazy
     55      lazy._UrlbarTestUtils = module;
     56    }
     57    // eslint-disable-next-line mozilla/valid-lazy
     58    return lazy._UrlbarTestUtils;
     59  },
     60 });
     61 
     62 // Test utils singletons need special handling. Since they are uninitialized in
     63 // cleanup functions, they must be re-initialized on each new test. That does
     64 // not happen automatically inside system modules like this one because system
     65 // module lifetimes are the app's lifetime, unlike individual browser chrome and
     66 // xpcshell tests.
     67 Object.defineProperty(lazy, "MerinoTestUtils", {
     68  get: () => {
     69    // eslint-disable-next-line mozilla/valid-lazy
     70    if (!lazy._MerinoTestUtils) {
     71      const { MerinoTestUtils: module } = ChromeUtils.importESModule(
     72        "resource://testing-common/MerinoTestUtils.sys.mjs"
     73      );
     74      module.init(gTestScope);
     75      gTestScope.registerCleanupFunction(() => {
     76        // Make sure the utils are re-initialized during the next test.
     77        // eslint-disable-next-line mozilla/valid-lazy
     78        lazy._MerinoTestUtils = null;
     79      });
     80      // eslint-disable-next-line mozilla/valid-lazy
     81      lazy._MerinoTestUtils = module;
     82    }
     83    // eslint-disable-next-line mozilla/valid-lazy
     84    return lazy._MerinoTestUtils;
     85  },
     86 });
     87 
     88 // TODO bug 1881409: Previously this was an empty object, but the Rust backend
     89 // seems to persist old config after ingesting an empty config object.
     90 const DEFAULT_CONFIG = {
     91  // Zero means there is no cap, the same as if this wasn't specified at all.
     92  show_less_frequently_cap: 0,
     93 };
     94 
     95 // The following properties and methods are copied from the test scope to the
     96 // test utils object so they can be easily accessed. Be careful about assuming a
     97 // particular property will be defined because depending on the scope -- browser
     98 // test or xpcshell test -- some may not be.
     99 const TEST_SCOPE_PROPERTIES = [
    100  "Assert",
    101  "EventUtils",
    102  "info",
    103  "registerCleanupFunction",
    104 ];
    105 
    106 /** @typedef {() => Promise<void>} cleanupFunctionType */
    107 
    108 /**
    109 * Test utils for quick suggest.
    110 */
    111 class _QuickSuggestTestUtils {
    112  /** @type {Assert} */
    113  Assert = undefined;
    114 
    115  /** @type {object} */
    116  EventUtils = undefined;
    117 
    118  /** @type {(message:string) => void} */
    119  info = undefined;
    120 
    121  /** @type {(cleanupFn: cleanupFunctionType) => void} */
    122  registerCleanupFunction = undefined;
    123 
    124  /**
    125   * Initializes the utils.
    126   *
    127   * @param {object} scope
    128   *   The global JS scope where tests are being run. This allows the instance
    129   *   to access test helpers like `Assert` that are available in the scope.
    130   */
    131  init(scope) {
    132    if (!scope) {
    133      throw new Error("QuickSuggestTestUtils() must be called with a scope");
    134    }
    135    gTestScope = scope;
    136    for (let p of TEST_SCOPE_PROPERTIES) {
    137      this[p] = scope[p];
    138    }
    139    // If you add other properties to `this`, null them in `uninit()`.
    140 
    141    scope.registerCleanupFunction?.(() => this.uninit());
    142  }
    143 
    144  /**
    145   * Uninitializes the utils. If they were created with a test scope that
    146   * defines `registerCleanupFunction()`, you don't need to call this yourself
    147   * because it will automatically be called as a cleanup function. Otherwise
    148   * you'll need to call this.
    149   */
    150  uninit() {
    151    gTestScope = null;
    152    for (let p of TEST_SCOPE_PROPERTIES) {
    153      this[p] = null;
    154    }
    155  }
    156 
    157  get RS_COLLECTION() {
    158    return {
    159      AMP: "quicksuggest-amp",
    160      OTHER: "quicksuggest-other",
    161    };
    162  }
    163 
    164  get RS_TYPE() {
    165    return {
    166      AMP: "amp",
    167      WIKIPEDIA: "wikipedia",
    168    };
    169  }
    170 
    171  get DEFAULT_CONFIG() {
    172    // Return a clone so callers can modify it.
    173    return Cu.cloneInto(DEFAULT_CONFIG, this);
    174  }
    175 
    176  /**
    177   * Sets up local remote settings and Merino servers, registers test
    178   * suggestions, and initializes Suggest.
    179   *
    180   * @param {object} [options]
    181   *   Options object
    182   * @param {Array} [options.remoteSettingsRecords]
    183   *   Array of remote settings records. Each item in this array should be a
    184   *   realistic remote settings record with some exceptions as noted below.
    185   *   For details see `RemoteSettingsServer.addRecords()`.
    186   *     - `record.attachment` - Optional. This should be the attachment itself
    187   *       and not its metadata. It should be a JSONable object.
    188   *     - `record.collection` - Optional. The name of the RS collection that
    189   *       the record should be added to. Defaults to "quicksuggest-other".
    190   * @param {Array} [options.merinoSuggestions]
    191   *   Array of Merino suggestion objects. If given, this function will start
    192   *   the mock Merino server and set appropriate online prefs so that Suggest
    193   *   will fetch suggestions from it. Otherwise Merino will not serve
    194   *   suggestions, but you can still set up Merino without using this function
    195   *   by using `MerinoTestUtils` directly.
    196   * @param {object} [options.config]
    197   *   The Suggest configuration object. This should not be the full remote
    198   *   settings record; only pass the object that should be set to the nested
    199   *   `configuration` object inside the record.
    200   * @param {Array} [options.prefs]
    201   *   An array of Suggest-related prefs to set. This is useful because setting
    202   *   some prefs, like feature gates, can cause Suggest to sync from remote
    203   *   settings; this function will set them, wait for sync to finish, and clear
    204   *   them when the cleanup function is called. Each item in this array should
    205   *   itself be a two-element array `[prefName, prefValue]` similar to the
    206   *   `set` array passed to `SpecialPowers.pushPrefEnv()`, except here pref
    207   *   names are relative to `browser.urlbar`.
    208   * @returns {Promise<(() => void) | (() => Promise<void>)>}
    209   *   An async cleanup function. This function is automatically registered as a
    210   *   cleanup function, so you only need to call it if your test needs to clean
    211   *   up Suggest before it ends, for example if you have a small number of
    212   *   tasks that need Suggest and it's not enabled throughout your test. The
    213   *   cleanup function is idempotent so there's no harm in calling it more than
    214   *   once. Be sure to `await` it.
    215   */
    216  async ensureQuickSuggestInit({
    217    remoteSettingsRecords = [],
    218    merinoSuggestions = null,
    219    config = DEFAULT_CONFIG,
    220    prefs = [],
    221  } = {}) {
    222    this.#log("ensureQuickSuggestInit", "Started");
    223 
    224    this.#log("ensureQuickSuggestInit", "Awaiting ExperimentAPI.init");
    225    const initializedExperimentAPI = await lazy.ExperimentAPI.init();
    226 
    227    this.#log("ensureQuickSuggestInit", "Awaiting ExperimentAPI.ready");
    228    await lazy.ExperimentAPI.ready();
    229 
    230    // Set up the local remote settings server.
    231    this.#log("ensureQuickSuggestInit", "Preparing remote settings server");
    232    if (!this.#remoteSettingsServer) {
    233      this.#remoteSettingsServer = new lazy.RemoteSettingsServer();
    234    }
    235 
    236    this.#remoteSettingsServer.removeRecords();
    237    for (let [collection, records] of this.#recordsByCollection(
    238      remoteSettingsRecords
    239    )) {
    240      await this.#remoteSettingsServer.addRecords({ collection, records });
    241    }
    242    await this.#remoteSettingsServer.addRecords({
    243      collection: this.RS_COLLECTION.OTHER,
    244      records: [{ type: "configuration", configuration: config }],
    245    });
    246 
    247    this.#log("ensureQuickSuggestInit", "Starting remote settings server");
    248    await this.#remoteSettingsServer.start();
    249    this.#log("ensureQuickSuggestInit", "Remote settings server started");
    250 
    251    // Init Suggest and force the region to US and the locale to en-US, which
    252    // will cause Suggest to be enabled along with all suggestion types that are
    253    // enabled in the US by default. Do this after setting up remote settings
    254    // because the Rust backend will immediately try to sync.
    255    this.#log(
    256      "ensureQuickSuggestInit",
    257      "Calling QuickSuggest.init() and setting prefs"
    258    );
    259    await lazy.QuickSuggest.init({ region: "US", locale: "en-US" });
    260 
    261    // Set prefs requested by the caller.
    262    for (let [name, value] of prefs) {
    263      lazy.UrlbarPrefs.set(name, value);
    264    }
    265 
    266    // Tell the Rust backend to use the local remote setting server.
    267    lazy.SharedRemoteSettingsService.updateServer({
    268      url: this.#remoteSettingsServer.url.toString(),
    269      bucketName: "main",
    270    });
    271    await lazy.QuickSuggest.rustBackend._test_setRemoteSettingsService(
    272      lazy.SharedRemoteSettingsService.rustService()
    273    );
    274 
    275    // Wait for the Rust backend to finish syncing.
    276    await this.forceSync();
    277 
    278    // Set up Merino. This can happen any time relative to Suggest init.
    279    if (merinoSuggestions) {
    280      this.#log("ensureQuickSuggestInit", "Setting up Merino server");
    281      await lazy.MerinoTestUtils.server.start();
    282      lazy.MerinoTestUtils.server.response.body.suggestions = merinoSuggestions;
    283      lazy.UrlbarPrefs.set("quicksuggest.online.available", true);
    284      lazy.UrlbarPrefs.set("quicksuggest.online.enabled", true);
    285      this.#log("ensureQuickSuggestInit", "Done setting up Merino server");
    286    }
    287 
    288    let cleanupCalled = false;
    289    let cleanup = async () => {
    290      if (!cleanupCalled) {
    291        cleanupCalled = true;
    292        await this.#uninitQuickSuggest(prefs, !!merinoSuggestions);
    293 
    294        if (initializedExperimentAPI) {
    295          // Only reset if we're in an xpcshell-test and actually initialized
    296          // the ExperimentAPI.
    297          lazy.ExperimentAPI._resetForTests();
    298        }
    299      }
    300    };
    301    this.registerCleanupFunction?.(cleanup);
    302 
    303    this.#log("ensureQuickSuggestInit", "Done");
    304    return cleanup;
    305  }
    306 
    307  async #uninitQuickSuggest(prefs, clearOnlinePrefs) {
    308    this.#log("#uninitQuickSuggest", "Started");
    309 
    310    // Reset prefs, which can cause the Rust backend to start syncing. Wait for
    311    // it to finish.
    312    for (let [name] of prefs) {
    313      lazy.UrlbarPrefs.clear(name);
    314    }
    315    await this.forceSync();
    316 
    317    this.#log("#uninitQuickSuggest", "Stopping remote settings server");
    318    await this.#remoteSettingsServer.stop();
    319 
    320    if (clearOnlinePrefs) {
    321      lazy.UrlbarPrefs.clear("quicksuggest.online.available");
    322      lazy.UrlbarPrefs.clear("quicksuggest.online.enabled");
    323    }
    324 
    325    await lazy.QuickSuggest.rustBackend._test_setRemoteSettingsService(null);
    326 
    327    this.#log("#uninitQuickSuggest", "Done");
    328  }
    329 
    330  /**
    331   * Removes all records from the local remote settings server and adds a new
    332   * batch of records.
    333   *
    334   * @param {Array} records
    335   *   Array of remote settings records. See `ensureQuickSuggestInit()`.
    336   * @param {object} [options]
    337   *   Options object.
    338   * @param {boolean} [options.forceSync]
    339   *   Whether to force Suggest to sync after updating the records.
    340   */
    341  async setRemoteSettingsRecords(records, { forceSync = true } = {}) {
    342    this.#log("setRemoteSettingsRecords", "Started");
    343 
    344    this.#remoteSettingsServer.removeRecords();
    345    for (let [collection, recs] of this.#recordsByCollection(records)) {
    346      await this.#remoteSettingsServer.addRecords({
    347        collection,
    348        records: recs,
    349      });
    350    }
    351 
    352    if (forceSync) {
    353      this.#log("setRemoteSettingsRecords", "Forcing sync");
    354      await this.forceSync();
    355    }
    356    this.#log("setRemoteSettingsRecords", "Done");
    357  }
    358 
    359  /**
    360   * Sets the quick suggest configuration. You should call this again with
    361   * `DEFAULT_CONFIG` before your test finishes. See also `withConfig()`.
    362   *
    363   * @param {object} config
    364   *   The quick suggest configuration object. This should not be the full
    365   *   remote settings record; only pass the object that should be set to the
    366   *   `configuration` nested object inside the record.
    367   */
    368  async setConfig(config) {
    369    this.#log("setConfig", "Started");
    370    let type = "configuration";
    371    this.#remoteSettingsServer.removeRecords({ type });
    372    await this.#remoteSettingsServer.addRecords({
    373      collection: this.RS_COLLECTION.OTHER,
    374      records: [{ type, configuration: config }],
    375    });
    376    this.#log("setConfig", "Forcing sync");
    377    await this.forceSync();
    378    this.#log("setConfig", "Done");
    379  }
    380 
    381  /**
    382   * Forces Suggest to sync with remote settings. This can be used to ensure
    383   * Suggest has finished all sync activity.
    384   */
    385  async forceSync() {
    386    this.#log("forceSync", "Started");
    387    if (lazy.QuickSuggest.rustBackend.isEnabled) {
    388      this.#log("forceSync", "Syncing Rust backend");
    389      await lazy.QuickSuggest.rustBackend._test_ingest();
    390      this.#log("forceSync", "Done syncing Rust backend");
    391    }
    392    this.#log("forceSync", "Done");
    393  }
    394 
    395  /**
    396   * Sets the quick suggest configuration, calls your callback, and restores the
    397   * previous configuration.
    398   *
    399   * @param {object} options
    400   *   The options object.
    401   * @param {object} options.config
    402   *   The configuration that should be used with the callback
    403   * @param {Function} options.callback
    404   *   Will be called with the configuration applied
    405   *
    406   * @see {@link setConfig}
    407   */
    408  async withConfig({ config, callback }) {
    409    let original = lazy.QuickSuggest.config;
    410    await this.setConfig(config);
    411    await callback();
    412    await this.setConfig(original);
    413  }
    414 
    415  /**
    416   * Returns an AMP (sponsored) suggestion suitable for storing in a remote
    417   * settings attachment.
    418   *
    419   * @returns {object}
    420   *   An AMP suggestion for storing in remote settings.
    421   */
    422  ampRemoteSettings({
    423    keywords = ["amp"],
    424    full_keywords = keywords.map(kw => [kw, 1]),
    425    url = "https://example.com/amp",
    426    title = "Amp Suggestion",
    427    score = 0.3,
    428  } = {}) {
    429    return {
    430      keywords,
    431      full_keywords,
    432      url,
    433      title,
    434      score,
    435      id: 1,
    436      click_url: "https://example.com/amp-click",
    437      impression_url: "https://example.com/amp-impression",
    438      advertiser: "Amp",
    439      iab_category: "22 - Shopping",
    440      icon: "1234",
    441    };
    442  }
    443 
    444  /**
    445   * Returns an expected AMP (sponsored) result that can be passed to
    446   * `check_results()` in xpcshell tests.
    447   *
    448   * @returns {object}
    449   *   An object that can be passed to `check_results()`.
    450   */
    451  ampResult({
    452    source = "rust",
    453    provider = "Amp",
    454    keyword = "amp",
    455    fullKeyword = keyword,
    456    title = "Amp Suggestion",
    457    url = "https://example.com/amp",
    458    originalUrl = url,
    459    icon = null,
    460    iconBlob = null,
    461    impressionUrl = "https://example.com/amp-impression",
    462    clickUrl = "https://example.com/amp-click",
    463    blockId = 1,
    464    advertiser = "Amp",
    465    iabCategory = "22 - Shopping",
    466    // Note that many callers use -1 here because they test without
    467    // the search suggestion provider.
    468    suggestedIndex = 0,
    469    isSuggestedIndexRelativeToGroup = true,
    470    isBestMatch = false,
    471    requestId = undefined,
    472    dismissalKey = undefined,
    473    descriptionL10n = { id: "urlbar-result-action-sponsored" },
    474    categories = [],
    475  } = {}) {
    476    let result = {
    477      suggestedIndex,
    478      isSuggestedIndexRelativeToGroup,
    479      isBestMatch,
    480      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
    481      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    482      heuristic: false,
    483      payload: {
    484        title: fullKeyword ? `${fullKeyword} — ${title}` : title,
    485        url,
    486        originalUrl,
    487        requestId,
    488        source,
    489        provider,
    490        isSponsored: true,
    491        sponsoredImpressionUrl: impressionUrl,
    492        sponsoredClickUrl: clickUrl,
    493        sponsoredBlockId: blockId,
    494        sponsoredAdvertiser: advertiser,
    495        sponsoredIabCategory: iabCategory,
    496        isBlockable: true,
    497        isManageable: true,
    498        telemetryType: "adm_sponsored",
    499      },
    500    };
    501 
    502    if (descriptionL10n) {
    503      result.payload.descriptionL10n = descriptionL10n;
    504    }
    505 
    506    if (result.payload.source == "rust") {
    507      result.payload.iconBlob = iconBlob;
    508      result.payload.suggestionObject = new lazy.Suggestion.Amp({
    509        title,
    510        url,
    511        rawUrl: originalUrl,
    512        icon: null,
    513        iconMimetype: null,
    514        fullKeyword,
    515        blockId,
    516        advertiser,
    517        iabCategory,
    518        categories,
    519        impressionUrl,
    520        clickUrl,
    521        rawClickUrl: clickUrl,
    522        score: 0.3,
    523        ftsMatchInfo: null,
    524      });
    525    } else {
    526      result.payload.icon = icon;
    527      if (typeof dismissalKey == "string") {
    528        result.payload.dismissalKey = dismissalKey;
    529      }
    530    }
    531 
    532    return result;
    533  }
    534 
    535  /**
    536   * Returns a Wikipedia (non-sponsored) suggestion suitable for storing in a
    537   * remote settings attachment.
    538   *
    539   * @returns {object}
    540   *   A Wikipedia suggestion for storing in remote settings.
    541   */
    542  wikipediaRemoteSettings({
    543    keywords = ["wikipedia"],
    544    url = "https://example.com/wikipedia",
    545    title = "Wikipedia Suggestion",
    546    score = 0.2,
    547  } = {}) {
    548    return {
    549      keywords,
    550      url,
    551      title,
    552      score,
    553      id: 2,
    554      click_url: "https://example.com/wikipedia-click",
    555      impression_url: "https://example.com/wikipedia-impression",
    556      advertiser: "Wikipedia",
    557      iab_category: "5 - Education",
    558      icon: "1234",
    559    };
    560  }
    561 
    562  /**
    563   * Returns an expected Wikipedia result that can be passed to
    564   * `check_results()` in xpcshell tests.
    565   *
    566   * @returns {object}
    567   *   An object that can be passed to `check_results()`.
    568   */
    569  wikipediaResult({
    570    source = "rust",
    571    provider = "Wikipedia",
    572    keyword = "wikipedia",
    573    fullKeyword = keyword,
    574    title = "Wikipedia Suggestion",
    575    url = "https://example.com/wikipedia",
    576    icon = null,
    577    iconBlob = null,
    578    suggestedIndex = -1,
    579    isSuggestedIndexRelativeToGroup = true,
    580    telemetryType = "adm_nonsponsored",
    581  } = {}) {
    582    let result = {
    583      suggestedIndex,
    584      isSuggestedIndexRelativeToGroup,
    585      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
    586      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    587      heuristic: false,
    588      payload: {
    589        title: fullKeyword ? `${fullKeyword} — ${title}` : title,
    590        url,
    591        icon,
    592        iconBlob,
    593        source,
    594        provider,
    595        telemetryType,
    596        isSponsored: false,
    597        isBlockable: true,
    598        isManageable: true,
    599      },
    600    };
    601 
    602    if (source == "rust") {
    603      result.payload.suggestionObject = new lazy.Suggestion.Wikipedia({
    604        title,
    605        url,
    606        icon: null,
    607        iconMimeType: null,
    608        fullKeyword,
    609      });
    610    }
    611 
    612    return result;
    613  }
    614 
    615  /**
    616   * Returns an AMO (addons) suggestion suitable for storing in a remote
    617   * settings attachment.
    618   *
    619   * @returns {object}
    620   *   An AMO suggestion for storing in remote settings.
    621   */
    622  amoRemoteSettings({
    623    keywords = ["amo"],
    624    url = "https://example.com/amo",
    625    title = "Amo Suggestion",
    626    score = 0.2,
    627  } = {}) {
    628    return {
    629      keywords,
    630      url,
    631      title,
    632      score,
    633      guid: "amo-suggestion@example.com",
    634      icon: "https://example.com/addon.svg",
    635      rating: "4.7",
    636      description: "Addon with score",
    637      number_of_ratings: 1256,
    638    };
    639  }
    640 
    641  /**
    642   * Returns a remote settings weather record.
    643   *
    644   * @returns {object}
    645   *   A weather record for storing in remote settings.
    646   */
    647  weatherRecord({
    648    keywords = ["weather"],
    649    min_keyword_length = undefined,
    650    score = 0.29,
    651  } = {}) {
    652    return {
    653      type: "weather",
    654      attachment: {
    655        keywords,
    656        min_keyword_length,
    657        score,
    658      },
    659    };
    660  }
    661 
    662  /**
    663   * Returns remote settings records containing geonames populated with some
    664   * cities.
    665   *
    666   * @returns {Array}
    667   *   One or more geonames records for storing in remote settings.
    668   */
    669  geonamesRecords() {
    670    let geonames = [
    671      // United States
    672      {
    673        id: 6252001,
    674        name: "United States",
    675        feature_class: "A",
    676        feature_code: "PCLI",
    677        country: "US",
    678        admin1: "00",
    679        population: 327167434,
    680        latitude: "39.76",
    681        longitude: "-98.5",
    682      },
    683      // Waterloo, AL
    684      {
    685        id: 4096497,
    686        name: "Waterloo",
    687        feature_class: "P",
    688        feature_code: "PPL",
    689        country: "US",
    690        admin1: "AL",
    691        admin2: "077",
    692        population: 200,
    693        latitude: "34.91814",
    694        longitude: "-88.0642",
    695      },
    696      // AL
    697      {
    698        id: 4829764,
    699        name: "Alabama",
    700        feature_class: "A",
    701        feature_code: "ADM1",
    702        country: "US",
    703        admin1: "AL",
    704        population: 4530315,
    705        latitude: "32.75041",
    706        longitude: "-86.75026",
    707      },
    708      // Waterloo, IA
    709      {
    710        id: 4880889,
    711        name: "Waterloo",
    712        feature_class: "P",
    713        feature_code: "PPLA2",
    714        country: "US",
    715        admin1: "IA",
    716        admin2: "013",
    717        admin3: "94597",
    718        population: 68460,
    719        latitude: "42.49276",
    720        longitude: "-92.34296",
    721      },
    722      // IA
    723      {
    724        id: 4862182,
    725        name: "Iowa",
    726        feature_class: "A",
    727        feature_code: "ADM1",
    728        country: "US",
    729        admin1: "IA",
    730        population: 2955010,
    731        latitude: "42.00027",
    732        longitude: "-93.50049",
    733      },
    734 
    735      // Made-up cities with the same name in the US and CA. The CA city has a
    736      // larger population.
    737      {
    738        id: 100,
    739        name: "US CA City",
    740        feature_class: "P",
    741        feature_code: "PPL",
    742        country: "US",
    743        admin1: "IA",
    744        population: 1,
    745        latitude: "38.06084",
    746        longitude: "-97.92977",
    747      },
    748      {
    749        id: 101,
    750        name: "US CA City",
    751        feature_class: "P",
    752        feature_code: "PPL",
    753        country: "CA",
    754        admin1: "08",
    755        population: 2,
    756        latitude: "45.50884",
    757        longitude: "-73.58781",
    758      },
    759 
    760      // Made-up cities that are only ~1.5 km apart.
    761      {
    762        id: 102,
    763        name: "Twin City A",
    764        feature_class: "P",
    765        feature_code: "PPL",
    766        country: "US",
    767        admin1: "GA",
    768        population: 1,
    769        latitude: "33.748889",
    770        longitude: "-84.39",
    771      },
    772      {
    773        id: 103,
    774        name: "Twin City B",
    775        feature_class: "P",
    776        feature_code: "PPL",
    777        country: "US",
    778        admin1: "GA",
    779        population: 2,
    780        latitude: "33.76",
    781        longitude: "-84.4",
    782      },
    783 
    784      // Tokyo
    785      {
    786        id: 1850147,
    787        name: "Tokyo",
    788        feature_class: "P",
    789        feature_code: "PPLC",
    790        country: "JP",
    791        admin1: "Tokyo-to",
    792        population: 9733276,
    793        latitude: "35.6895",
    794        longitude: "139.69171",
    795      },
    796 
    797      // UK
    798      {
    799        id: 2635167,
    800        name: "United Kingdom of Great Britain and Northern Ireland",
    801        feature_class: "A",
    802        feature_code: "PCLI",
    803        country: "GB",
    804        admin1: "00",
    805        population: 66488991,
    806        latitude: "54.75844",
    807        longitude: "-2.69531",
    808      },
    809      // England
    810      {
    811        id: 6269131,
    812        name: "England",
    813        feature_class: "A",
    814        feature_code: "ADM1",
    815        country: "GB",
    816        admin1: "ENG",
    817        population: 57106398,
    818        latitude: "52.16045",
    819        longitude: "-0.70312",
    820      },
    821      // Liverpool (metropolitan borough, admin2 for Liverpool city)
    822      {
    823        id: 3333167,
    824        name: "Liverpool",
    825        feature_class: "A",
    826        feature_code: "ADM2",
    827        country: "GB",
    828        admin1: "ENG",
    829        admin2: "H8",
    830        population: 484578,
    831        latitude: "53.41667",
    832        longitude: "-2.91667",
    833      },
    834      // Liverpool (city)
    835      {
    836        id: 2644210,
    837        name: "Liverpool",
    838        feature_class: "P",
    839        feature_code: "PPLA2",
    840        country: "GB",
    841        admin1: "ENG",
    842        admin2: "H8",
    843        population: 864122,
    844        latitude: "53.41058",
    845        longitude: "-2.97794",
    846      },
    847    ];
    848 
    849    return [
    850      {
    851        type: "geonames-2",
    852        attachment: geonames,
    853      },
    854    ];
    855  }
    856 
    857  /**
    858   * Returns remote settings records containing geonames alternates (alternate
    859   * names) populated with some names.
    860   *
    861   * @returns {Array}
    862   *   One or more geonames alternates records for storing in remote settings.
    863   */
    864  geonamesAlternatesRecords() {
    865    return [
    866      {
    867        type: "geonames-alternates",
    868        attachment: [
    869          {
    870            language: "abbr",
    871            alternates_by_geoname_id: [
    872              [4829764, ["AL"]],
    873              [4862182, ["IA"]],
    874              [2635167, ["UK"]],
    875            ],
    876          },
    877        ],
    878      },
    879      {
    880        type: "geonames-alternates",
    881        attachment: [
    882          {
    883            language: "en",
    884            alternates_by_geoname_id: [
    885              [
    886                2635167,
    887                [
    888                  {
    889                    name: "United Kingdom",
    890                    is_preferred: true,
    891                    is_short: true,
    892                  },
    893                ],
    894              ],
    895            ],
    896          },
    897        ],
    898      },
    899    ];
    900  }
    901 
    902  /**
    903   * Returns an expected AMO (addons) result that can be passed to
    904   * `check_results()` in xpcshell tests.
    905   *
    906   * @returns {object}
    907   *   An object that can be passed to `check_results()`.
    908   */
    909  amoResult({
    910    source = "rust",
    911    provider = "Amo",
    912    title = "Amo Suggestion",
    913    description = "Amo description",
    914    url = "https://example.com/amo",
    915    originalUrl = "https://example.com/amo",
    916    icon = null,
    917    setUtmParams = true,
    918  }) {
    919    if (setUtmParams) {
    920      let parsedUrl = new URL(url);
    921      parsedUrl.searchParams.set("utm_medium", "firefox-desktop");
    922      parsedUrl.searchParams.set("utm_source", "firefox-suggest");
    923      url = parsedUrl.href;
    924    }
    925 
    926    let result = {
    927      isBestMatch: true,
    928      suggestedIndex: 1,
    929      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
    930      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
    931      heuristic: false,
    932      payload: {
    933        source,
    934        provider,
    935        title,
    936        description,
    937        url,
    938        originalUrl,
    939        icon,
    940        isSponsored: false,
    941        shouldShowUrl: true,
    942        bottomTextL10n: {
    943          id: "firefox-suggest-addons-recommended",
    944        },
    945        helpUrl: lazy.QuickSuggest.HELP_URL,
    946        telemetryType: "amo",
    947      },
    948    };
    949 
    950    if (source == "rust") {
    951      result.payload.suggestionObject = new lazy.Suggestion.Amo({
    952        title,
    953        url: originalUrl,
    954        iconUrl: icon,
    955        description,
    956        rating: "4.7",
    957        numberOfRatings: 1,
    958        guid: "amo-suggestion@example.com",
    959        score: 0.2,
    960      });
    961    }
    962 
    963    return result;
    964  }
    965 
    966  /**
    967   * Returns an expected MDN result that can be passed to `check_results()` in
    968   * xpcshell tests.
    969   *
    970   * @returns {object}
    971   *   An object that can be passed to `check_results()`.
    972   */
    973  mdnResult({ url, title, description }) {
    974    let finalUrl = new URL(url);
    975    finalUrl.searchParams.set("utm_medium", "firefox-desktop");
    976    finalUrl.searchParams.set("utm_source", "firefox-suggest");
    977    finalUrl.searchParams.set(
    978      "utm_campaign",
    979      "firefox-mdn-web-docs-suggestion-experiment"
    980    );
    981    finalUrl.searchParams.set("utm_content", "treatment");
    982 
    983    return {
    984      isBestMatch: true,
    985      suggestedIndex: 1,
    986      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
    987      source: lazy.UrlbarUtils.RESULT_SOURCE.OTHER_NETWORK,
    988      heuristic: false,
    989      payload: {
    990        telemetryType: "mdn",
    991        title,
    992        url: finalUrl.href,
    993        originalUrl: url,
    994        isSponsored: false,
    995        description,
    996        icon: "chrome://global/skin/icons/mdn.svg",
    997        shouldShowUrl: true,
    998        bottomTextL10n: {
    999          id: "firefox-suggest-mdn-bottom-text",
   1000        },
   1001        source: "rust",
   1002        provider: "Mdn",
   1003        suggestionObject: new lazy.Suggestion.Mdn({
   1004          title,
   1005          url,
   1006          description,
   1007          score: 0.2,
   1008        }),
   1009      },
   1010    };
   1011  }
   1012 
   1013  /**
   1014   * Returns an expected Yelp result that can be passed to `check_results()` in
   1015   * xpcshell tests.
   1016   *
   1017   * @returns {object}
   1018   *   An object that can be passed to `check_results()`.
   1019   */
   1020  yelpResult({
   1021    url,
   1022    title = undefined,
   1023    titleL10n = undefined,
   1024    source = "rust",
   1025    provider = "Yelp",
   1026    isTopPick = false,
   1027    // The logic for the default Yelp `suggestedIndex` is complex and depends on
   1028    // whether `UrlbarProviderSearchSuggestions` is active and whether search
   1029    // suggestions are shown first. By default -- when the answer to both of
   1030    // those questions is Yes -- Yelp's `suggestedIndex` is 0.
   1031    suggestedIndex = 0,
   1032    isSuggestedIndexRelativeToGroup = true,
   1033    originalUrl = undefined,
   1034    suggestedType = lazy.YelpSubjectType.SERVICE,
   1035  }) {
   1036    const utmParameters = "&utm_medium=partner&utm_source=mozilla";
   1037 
   1038    originalUrl ??= url;
   1039    originalUrl = new URL(originalUrl);
   1040    originalUrl.searchParams.delete("find_loc");
   1041    originalUrl = originalUrl.toString();
   1042 
   1043    url += utmParameters;
   1044 
   1045    if (isTopPick) {
   1046      suggestedIndex = 1;
   1047      isSuggestedIndexRelativeToGroup = false;
   1048    }
   1049 
   1050    let result = {
   1051      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
   1052      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
   1053      isBestMatch: !!isTopPick,
   1054      suggestedIndex,
   1055      isSuggestedIndexRelativeToGroup,
   1056      heuristic: false,
   1057      payload: {
   1058        source,
   1059        provider,
   1060        telemetryType: "yelp",
   1061        bottomTextL10n: {
   1062          id: "firefox-suggest-yelp-bottom-text",
   1063        },
   1064        url,
   1065        originalUrl,
   1066        title,
   1067        titleL10n,
   1068        icon: null,
   1069        isSponsored: true,
   1070      },
   1071    };
   1072 
   1073    if (source == "rust") {
   1074      result.payload.suggestionObject = new lazy.Suggestion.Yelp({
   1075        url: originalUrl,
   1076        // `title` will be undefined if the caller passed in `titleL10n`
   1077        // instead, but the Rust suggestion must be created with a string title.
   1078        // The Rust suggestion title doesn't actually matter since no test
   1079        // relies on it directly or indirectly. Pick an arbitrary string, and
   1080        // make it distinctive so it's easier to track down bugs in case it does
   1081        // start to matter at some point.
   1082        title: title ?? "<QuickSuggestTestUtils Yelp suggestion>",
   1083        icon: null,
   1084        iconMimeType: null,
   1085        score: 0.2,
   1086        hasLocationSign: false,
   1087        subjectExactMatch: false,
   1088        subjectType: suggestedType,
   1089        locationParam: "find_loc",
   1090      });
   1091    }
   1092 
   1093    return result;
   1094  }
   1095 
   1096  /**
   1097   * Returns an expected weather result that can be passed to `check_results()`
   1098   * in xpcshell tests.
   1099   *
   1100   * @returns {object}
   1101   *   An object that can be passed to `check_results()`.
   1102   */
   1103  weatherResult({
   1104    source = "rust",
   1105    provider = "Weather",
   1106    titleL10n = undefined,
   1107    temperatureUnit = undefined,
   1108  } = {}) {
   1109    if (!temperatureUnit) {
   1110      temperatureUnit =
   1111        Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
   1112    }
   1113 
   1114    if (!titleL10n) {
   1115      titleL10n = {
   1116        id: "urlbar-result-weather-title",
   1117        args: {
   1118          city: lazy.MerinoTestUtils.WEATHER_SUGGESTION.city_name,
   1119          region: lazy.MerinoTestUtils.WEATHER_SUGGESTION.region_code,
   1120        },
   1121      };
   1122    }
   1123    titleL10n = {
   1124      ...titleL10n,
   1125      args: {
   1126        ...titleL10n.args,
   1127        temperature:
   1128          lazy.MerinoTestUtils.WEATHER_SUGGESTION.current_conditions
   1129            .temperature[temperatureUnit],
   1130        unit: temperatureUnit.toUpperCase(),
   1131      },
   1132      parseMarkup: true,
   1133    };
   1134 
   1135    return {
   1136      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
   1137      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
   1138      heuristic: false,
   1139      suggestedIndex: 1,
   1140      isRichSuggestion: true,
   1141      richSuggestionIconVariation: "6",
   1142      payload: {
   1143        titleL10n,
   1144        url: lazy.MerinoTestUtils.WEATHER_SUGGESTION.url,
   1145        bottomTextL10n: {
   1146          id: "urlbar-result-weather-provider-sponsored",
   1147          args: { provider: "AccuWeather®" },
   1148        },
   1149        source,
   1150        provider,
   1151        isSponsored: true,
   1152        telemetryType: "weather",
   1153        helpUrl: lazy.QuickSuggest.HELP_URL,
   1154      },
   1155    };
   1156  }
   1157 
   1158  /**
   1159   * Asserts a result is a quick suggest result.
   1160   *
   1161   * @param {object} options
   1162   *   The options object.
   1163   * @param {string} options.url
   1164   *   The expected URL. At least one of `url` and `originalUrl` must be given.
   1165   * @param {string} options.originalUrl
   1166   *   The expected original URL (the URL with an unreplaced timestamp
   1167   *   template). At least one of `url` and `originalUrl` must be given.
   1168   * @param {object} options.window
   1169   *   The window that should be used for this assertion
   1170   * @param {number} [options.index]
   1171   *   The expected index of the quick suggest result. Pass -1 to use the index
   1172   *   of the last result.
   1173   * @param {boolean} [options.isSponsored]
   1174   *   Whether the result is expected to be sponsored.
   1175   * @param {boolean} [options.isBestMatch]
   1176   *   Whether the result is expected to be a best match.
   1177   * @param {boolean} [options.isManageable]
   1178   *   Whether the result is expected to show Manage result menu item.
   1179   * @param {boolean} [options.hasSponsoredLabel]
   1180   *   Whether the result is expected to show the "Sponsored" label below the
   1181   *   title.
   1182   * @returns {Promise<object>}
   1183   *   The quick suggest result.
   1184   */
   1185  async assertIsQuickSuggest({
   1186    url,
   1187    originalUrl,
   1188    window,
   1189    index = -1,
   1190    isSponsored = true,
   1191    isBestMatch = false,
   1192    isManageable = true,
   1193    hasSponsoredLabel = isSponsored || isBestMatch,
   1194  }) {
   1195    this.Assert.ok(
   1196      url || originalUrl,
   1197      "At least one of url and originalUrl is specified"
   1198    );
   1199 
   1200    if (index < 0) {
   1201      let resultCount = lazy.UrlbarTestUtils.getResultCount(window);
   1202      if (isBestMatch) {
   1203        index = 1;
   1204        this.Assert.greater(
   1205          resultCount,
   1206          1,
   1207          "Sanity check: Result count should be > 1"
   1208        );
   1209      } else {
   1210        index = resultCount - 1;
   1211        this.Assert.greater(
   1212          resultCount,
   1213          0,
   1214          "Sanity check: Result count should be > 0"
   1215        );
   1216      }
   1217    }
   1218 
   1219    let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
   1220      window,
   1221      index
   1222    );
   1223    let { result } = details;
   1224 
   1225    this.#log(
   1226      "assertIsQuickSuggest",
   1227      `Checking actual result at index ${index}: ` + JSON.stringify(result)
   1228    );
   1229 
   1230    this.Assert.equal(
   1231      result.providerName,
   1232      "UrlbarProviderQuickSuggest",
   1233      "Result provider name is UrlbarProviderQuickSuggest"
   1234    );
   1235    this.Assert.equal(details.type, lazy.UrlbarUtils.RESULT_TYPE.URL);
   1236    this.Assert.equal(details.isSponsored, isSponsored, "Result isSponsored");
   1237    if (url) {
   1238      this.Assert.equal(details.url, url, "Result URL");
   1239    }
   1240    if (originalUrl) {
   1241      this.Assert.equal(
   1242        result.payload.originalUrl,
   1243        originalUrl,
   1244        "Result original URL"
   1245      );
   1246    }
   1247 
   1248    this.Assert.equal(!!result.isBestMatch, isBestMatch, "Result isBestMatch");
   1249 
   1250    let { row } = details.element;
   1251 
   1252    let sponsoredElement = row._elements.get("description");
   1253    if (hasSponsoredLabel) {
   1254      this.Assert.ok(sponsoredElement, "Result sponsored label element exists");
   1255      this.Assert.equal(
   1256        sponsoredElement.textContent,
   1257        isSponsored ? "Sponsored" : "",
   1258        "Result sponsored label"
   1259      );
   1260    } else {
   1261      this.Assert.ok(
   1262        !sponsoredElement?.textContent,
   1263        "Result sponsored label element should not exist"
   1264      );
   1265    }
   1266 
   1267    this.Assert.equal(
   1268      result.payload.isManageable,
   1269      isManageable,
   1270      "Result isManageable"
   1271    );
   1272 
   1273    if (!isManageable) {
   1274      this.Assert.equal(
   1275        result.payload.helpUrl,
   1276        lazy.QuickSuggest.HELP_URL,
   1277        "Result helpURL"
   1278      );
   1279    }
   1280 
   1281    this.Assert.ok(
   1282      row._buttons.get("result-menu"),
   1283      "The menu button should be present"
   1284    );
   1285 
   1286    return details;
   1287  }
   1288 
   1289  /**
   1290   * Asserts a result is not a quick suggest result.
   1291   *
   1292   * @param {object} window
   1293   *   The window that should be used for this assertion
   1294   * @param {number} index
   1295   *   The index of the result.
   1296   */
   1297  async assertIsNotQuickSuggest(window, index) {
   1298    let details = await lazy.UrlbarTestUtils.getDetailsOfResultAt(
   1299      window,
   1300      index
   1301    );
   1302    this.Assert.notEqual(
   1303      details.result.providerName,
   1304      "UrlbarProviderQuickSuggest",
   1305      `Result at index ${index} is not provided by UrlbarProviderQuickSuggest`
   1306    );
   1307  }
   1308 
   1309  /**
   1310   * Asserts that none of the results are quick suggest results.
   1311   *
   1312   * @param {object} window
   1313   *   The window that should be used for this assertion
   1314   */
   1315  async assertNoQuickSuggestResults(window) {
   1316    for (let i = 0; i < lazy.UrlbarTestUtils.getResultCount(window); i++) {
   1317      await this.assertIsNotQuickSuggest(window, i);
   1318    }
   1319  }
   1320 
   1321  /**
   1322   * Asserts that URLs in a result's payload have the timestamp template
   1323   * substring replaced with real timestamps.
   1324   *
   1325   * @param {UrlbarResult} result The results to check
   1326   * @param {object} urls
   1327   *   An object that contains the expected payload properties with template
   1328   *   substrings. For example:
   1329   *   ```js
   1330   *   {
   1331   *     url: "https://example.com/foo-%YYYYMMDDHH%",
   1332   *     sponsoredClickUrl: "https://example.com/bar-%YYYYMMDDHH%",
   1333   *   }
   1334   *   ```
   1335   */
   1336  assertTimestampsReplaced(result, urls) {
   1337    let { TIMESTAMP_TEMPLATE, TIMESTAMP_LENGTH } = lazy.AmpSuggestions;
   1338 
   1339    // Parse the timestamp strings from each payload property and save them in
   1340    // `urls[key].timestamp`.
   1341    urls = { ...urls };
   1342    for (let [key, url] of Object.entries(urls)) {
   1343      let index = url.indexOf(TIMESTAMP_TEMPLATE);
   1344      this.Assert.ok(
   1345        index >= 0,
   1346        `Timestamp template ${TIMESTAMP_TEMPLATE} is in URL ${url} for key ${key}`
   1347      );
   1348      let value = result.payload[key];
   1349      this.Assert.ok(value, "Key is in result payload: " + key);
   1350      let timestamp = value.substring(index, index + TIMESTAMP_LENGTH);
   1351 
   1352      // Set `urls[key]` to an object that's helpful in the logged info message
   1353      // below.
   1354      urls[key] = { url, value, timestamp };
   1355    }
   1356 
   1357    this.#log(
   1358      "assertTimestampsReplaced",
   1359      "Parsed timestamps: " + JSON.stringify(urls)
   1360    );
   1361 
   1362    // Make a set of unique timestamp strings. There should only be one.
   1363    let { timestamp } = Object.values(urls)[0];
   1364    this.Assert.deepEqual(
   1365      [...new Set(Object.values(urls).map(o => o.timestamp))],
   1366      [timestamp],
   1367      "There's only one unique timestamp string"
   1368    );
   1369 
   1370    // Parse the parts of the timestamp string.
   1371    let year = timestamp.slice(0, -6);
   1372    let month = timestamp.slice(-6, -4);
   1373    let day = timestamp.slice(-4, -2);
   1374    let hour = timestamp.slice(-2);
   1375    let date = new Date(year, month - 1, day, hour);
   1376 
   1377    // The timestamp should be no more than two hours in the past. Typically it
   1378    // will be the same as the current hour, but since its resolution is in
   1379    // terms of hours and it's possible the test may have crossed over into a
   1380    // new hour as it was running, allow for the previous hour.
   1381    this.Assert.less(
   1382      Date.now() - 2 * 60 * 60 * 1000,
   1383      date.getTime(),
   1384      "Timestamp is within the past two hours"
   1385    );
   1386  }
   1387 
   1388  /**
   1389   * Calls a callback while enrolled in a mock Nimbus experiment. The experiment
   1390   * is automatically unenrolled and cleaned up after the callback returns.
   1391   *
   1392   * @param {object} options
   1393   *   Options for the mock experiment.
   1394   * @param {Function} options.callback
   1395   *   The callback to call while enrolled in the mock experiment.
   1396   * @param {object} options.valueOverrides
   1397   *   Values for feature variables.
   1398   */
   1399  async withExperiment({ callback, ...options }) {
   1400    let doExperimentCleanup = await this.enrollExperiment(options);
   1401    await callback();
   1402    await doExperimentCleanup();
   1403  }
   1404 
   1405  /**
   1406   * Enrolls in a mock Nimbus experiment.
   1407   *
   1408   * @param {object} options
   1409   *   Options for the mock experiment.
   1410   * @param {object} [options.valueOverrides]
   1411   *   Values for feature variables.
   1412   * @returns {Promise<Function>}
   1413   *   The experiment cleanup function (async).
   1414   */
   1415  async enrollExperiment({ valueOverrides = {} }) {
   1416    this.#log("enrollExperiment", "Awaiting ExperimentAPI.ready");
   1417    await lazy.ExperimentAPI.ready();
   1418 
   1419    let doExperimentCleanup =
   1420      await lazy.NimbusTestUtils.enrollWithFeatureConfig({
   1421        enabled: true,
   1422        featureId: "urlbar",
   1423        value: valueOverrides,
   1424      });
   1425 
   1426    return async () => {
   1427      this.#log("enrollExperiment.cleanup", "Awaiting experiment cleanup");
   1428      await doExperimentCleanup();
   1429    };
   1430  }
   1431 
   1432  /**
   1433   * Sets the app's home region and locale, calls your callback, and resets the
   1434   * region and locale.
   1435   *
   1436   * @param {object} options
   1437   *   Options object.
   1438   * @param {Function} options.callback
   1439   *  The callback to call.
   1440   * @param {string} [options.region]
   1441   *   The region to set. See `setRegionAndLocale`.
   1442   * @param {string} [options.locale]
   1443   *   The locale to set. See `setRegionAndLocale`.
   1444   * @param {boolean} [options.skipSuggestReset]
   1445   *   Whether Suggest reset should be skipped after setting the new region and
   1446   *   locale. See `setRegionAndLocale`.
   1447   */
   1448  async withRegionAndLocale({
   1449    callback,
   1450    region = undefined,
   1451    locale = undefined,
   1452    skipSuggestReset = false,
   1453  }) {
   1454    this.#log("withRegionAndLocale", "Calling setRegionAndLocale");
   1455    let cleanup = await this.setRegionAndLocale({
   1456      region,
   1457      locale,
   1458      skipSuggestReset,
   1459    });
   1460 
   1461    this.#log("withRegionAndLocale", "Calling callback");
   1462    await callback();
   1463 
   1464    this.#log("withRegionAndLocale", "Calling cleanup");
   1465    await cleanup();
   1466 
   1467    this.#log("withRegionAndLocale", "Done");
   1468  }
   1469 
   1470  /**
   1471   * Sets the app's home region and locale and waits for all relevant
   1472   * notifications. Returns an async cleanup function that should be called to
   1473   * restore the previous region and locale.
   1474   *
   1475   * See also `withRegionAndLocale`.
   1476   *
   1477   * @param {object} options
   1478   *   Options object.
   1479   * @param {string} [options.region]
   1480   *   The home region to set. If falsey, the current region will remain
   1481   *   unchanged.
   1482   * @param {string} [options.locale]
   1483   *   The locale to set. If falsey, the current locale will remain unchanged.
   1484   * @param {Array} [options.availableLocales]
   1485   *   Normally this should be left undefined. If defined,
   1486   *   `Services.locale.availableLocales` will be set to this array. Otherwise
   1487   *   it will be set to `[locale]`.
   1488   * @param {boolean} [options.skipSuggestReset]
   1489   *   Normally this function resets `QuickSuggest` after the new region and
   1490   *   locale are set, which will cause all Suggest prefs to be set according to
   1491   *   the new region and locale. That's undesirable in some cases, for example
   1492   *   when you're testing region/locale combinations where Suggest or one of
   1493   *   its features isn't enabled by default. Pass in true to skip reset.
   1494   * @returns {Promise<Function>}
   1495   *   An async cleanup function.
   1496   */
   1497  async setRegionAndLocale({
   1498    region = undefined,
   1499    locale = undefined,
   1500    availableLocales = undefined,
   1501    skipSuggestReset = false,
   1502  }) {
   1503    let oldRegion = lazy.Region.home;
   1504    let newRegion = region ?? oldRegion;
   1505    let regionPromise = this.#waitForAllRegionChanges(newRegion);
   1506    if (region) {
   1507      this.#log("setRegionAndLocale", "Setting region: " + region);
   1508      lazy.Region._setHomeRegion(region, true);
   1509    }
   1510 
   1511    let {
   1512      availableLocales: oldAvailableLocales,
   1513      requestedLocales: oldRequestedLocales,
   1514    } = Services.locale;
   1515    let newLocale = locale ?? oldRequestedLocales[0];
   1516    let localePromise = this.#waitForAllLocaleChanges(newLocale);
   1517    if (locale) {
   1518      this.#log("setRegionAndLocale", "Setting locale: " + locale);
   1519      Services.locale.availableLocales = availableLocales ?? [locale];
   1520      Services.locale.requestedLocales = [locale];
   1521    }
   1522 
   1523    this.#log("setRegionAndLocale", "Waiting for region and locale changes");
   1524    await Promise.all([regionPromise, localePromise]);
   1525 
   1526    this.Assert.equal(
   1527      lazy.Region.home,
   1528      newRegion,
   1529      "Region is now " + newRegion
   1530    );
   1531    this.Assert.equal(
   1532      Services.locale.appLocaleAsBCP47,
   1533      newLocale,
   1534      "App locale is now " + newLocale
   1535    );
   1536 
   1537    if (!skipSuggestReset) {
   1538      this.#log("setRegionAndLocale", "Waiting for _test_reset");
   1539      await lazy.QuickSuggest._test_reset();
   1540    }
   1541 
   1542    if (this.#remoteSettingsServer) {
   1543      this.#log("setRegionAndLocale", "Waiting for forceSync");
   1544      await this.forceSync();
   1545    }
   1546 
   1547    this.#log("setRegionAndLocale", "Done");
   1548 
   1549    return async () => {
   1550      this.#log(
   1551        "setRegionAndLocale",
   1552        "Cleanup started, calling setRegionAndLocale with old region and locale"
   1553      );
   1554      await this.setRegionAndLocale({
   1555        skipSuggestReset,
   1556        region: oldRegion,
   1557        locale: oldRequestedLocales[0],
   1558        availableLocales: oldAvailableLocales,
   1559      });
   1560      this.#log("setRegionAndLocale", "Cleanup done");
   1561    };
   1562  }
   1563 
   1564  async #waitForAllRegionChanges(region) {
   1565    await lazy.TestUtils.waitForCondition(
   1566      () => lazy.SharedRemoteSettingsService.country == region,
   1567      "Waiting for SharedRemoteSettingsService.country to be " + region
   1568    );
   1569  }
   1570 
   1571  async #waitForAllLocaleChanges(locale) {
   1572    let promises = [
   1573      lazy.TestUtils.waitForCondition(
   1574        () => lazy.SharedRemoteSettingsService.locale == locale,
   1575        "#waitForAllLocaleChanges: Waiting for SharedRemoteSettingsService.locale to be " +
   1576          locale
   1577      ),
   1578    ];
   1579 
   1580    if (locale == Services.locale.requestedLocales[0]) {
   1581      // "intl:app-locales-changed" isn't sent when the locale doesn't change.
   1582      this.#log("#waitForAllLocaleChanges", "Locale is already " + locale);
   1583    } else {
   1584      this.#log(
   1585        "#waitForAllLocaleChanges",
   1586        "Waiting for intl:app-locales-changed"
   1587      );
   1588      promises.push(lazy.TestUtils.topicObserved("intl:app-locales-changed"));
   1589 
   1590      // Wait for the search service to reload engines. Otherwise tests can fail
   1591      // in strange ways due to internal search service state during shutdown.
   1592      // It won't always reload engines but it's hard to tell in advance when it
   1593      // won't, so also set a timeout.
   1594      this.#log("#waitForAllLocaleChanges", "Waiting for TOPIC_SEARCH_SERVICE");
   1595      promises.push(
   1596        Promise.race([
   1597          lazy.TestUtils.topicObserved(
   1598            lazy.SearchUtils.TOPIC_SEARCH_SERVICE,
   1599            (subject, data) => {
   1600              this.#log(
   1601                "#waitForAllLocaleChanges",
   1602                "Observed TOPIC_SEARCH_SERVICE with data: " + data
   1603              );
   1604              return data == "engines-reloaded";
   1605            }
   1606          ),
   1607          new Promise(resolve => {
   1608            lazy.setTimeout(() => {
   1609              this.#log(
   1610                "#waitForAllLocaleChanges",
   1611                "Timed out waiting for TOPIC_SEARCH_SERVICE (not an error)"
   1612              );
   1613              resolve();
   1614            }, 1000);
   1615          }),
   1616        ])
   1617      );
   1618    }
   1619 
   1620    await Promise.all(promises);
   1621    this.#log("#waitForAllLocaleChanges", "Done");
   1622  }
   1623 
   1624  #log(fnName, msg) {
   1625    this.info?.(`QuickSuggestTestUtils.${fnName} ${msg}`);
   1626  }
   1627 
   1628  #recordsByCollection(records) {
   1629    // Make a Map from collection name to the array of records that should be
   1630    // added to that collection.
   1631    let recordsByCollection = records.reduce((memo, record) => {
   1632      let collection = record.collection || this.RS_COLLECTION.OTHER;
   1633      let recs = memo.get(collection);
   1634      if (!recs) {
   1635        recs = [];
   1636        memo.set(collection, recs);
   1637      }
   1638      recs.push(record);
   1639      return memo;
   1640    }, new Map());
   1641 
   1642    // Make sure the two main collections, "quicksuggest-amp" and
   1643    // "quicksuggest-other", are present. Otherwise the Rust component will log
   1644    // 404 errors because it expects them to exist. The errors are harmless but
   1645    // annoying.
   1646    for (let collection of Object.values(this.RS_COLLECTION)) {
   1647      if (!recordsByCollection.has(collection)) {
   1648        recordsByCollection.set(collection, []);
   1649      }
   1650    }
   1651 
   1652    return recordsByCollection;
   1653  }
   1654 
   1655  #remoteSettingsServer;
   1656 }
   1657 
   1658 export var QuickSuggestTestUtils = new _QuickSuggestTestUtils();