tor-browser

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

test_merinoClient.js (26242B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 // Test for MerinoClient.
      6 
      7 "use strict";
      8 
      9 ChromeUtils.defineESModuleGetters(this, {
     10  MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs",
     11  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     12  NimbusTestUtils: "resource://testing-common/NimbusTestUtils.sys.mjs",
     13  ObliviousHTTP: "resource://gre/modules/ObliviousHTTP.sys.mjs",
     14 });
     15 
     16 // Set the `merino.timeoutMs` pref to a large value so that the client will not
     17 // inadvertently time out during fetches. This is especially important on CI and
     18 // when running this test in verify mode. Tasks that specifically test timeouts
     19 // may need to set a more reasonable value for their duration.
     20 const TEST_TIMEOUT_MS = 30000;
     21 
     22 // The expected suggestion objects returned from `MerinoClient.fetch()`.
     23 const EXPECTED_MERINO_SUGGESTIONS = [];
     24 
     25 const { SEARCH_PARAMS } = MerinoClient;
     26 
     27 let gClient;
     28 
     29 add_setup(async function init() {
     30  // Set up FOG (Glean).
     31  do_get_profile();
     32  Services.fog.initializeFOG();
     33 
     34  UrlbarPrefs.set("merino.timeoutMs", TEST_TIMEOUT_MS);
     35  registerCleanupFunction(() => {
     36    UrlbarPrefs.clear("merino.timeoutMs");
     37  });
     38 
     39  gClient = new MerinoClient();
     40  await MerinoTestUtils.server.start();
     41 
     42  for (let suggestion of MerinoTestUtils.server.response.body.suggestions) {
     43    EXPECTED_MERINO_SUGGESTIONS.push({
     44      ...suggestion,
     45      request_id: MerinoTestUtils.server.response.body.request_id,
     46      source: "merino",
     47    });
     48  }
     49 });
     50 
     51 // Checks client names.
     52 add_task(async function name() {
     53  Assert.equal(
     54    gClient.name,
     55    "anonymous",
     56    "gClient name is 'anonymous' since it wasn't given a name"
     57  );
     58 
     59  let client = new MerinoClient("New client");
     60  Assert.equal(client.name, "New client", "newClient name is correct");
     61 });
     62 
     63 // Does a successful fetch.
     64 add_task(async function success() {
     65  await fetchAndCheckSuggestions({
     66    expected: EXPECTED_MERINO_SUGGESTIONS,
     67  });
     68 
     69  Assert.equal(
     70    gClient.lastFetchStatus,
     71    "success",
     72    "The request successfully finished"
     73  );
     74  assertTelemetry({
     75    latency: {
     76      200: {
     77        count: 1,
     78      },
     79    },
     80  });
     81 });
     82 
     83 // Does a successful fetch that doesn't return any suggestions.
     84 add_task(async function noSuggestions() {
     85  let { suggestions } = MerinoTestUtils.server.response.body;
     86  MerinoTestUtils.server.response.body.suggestions = [];
     87 
     88  await fetchAndCheckSuggestions({
     89    expected: [],
     90  });
     91 
     92  Assert.equal(
     93    gClient.lastFetchStatus,
     94    "no_suggestion",
     95    "The request successfully finished without suggestions"
     96  );
     97  assertTelemetry({
     98    latency: {
     99      200: {
    100        count: 1,
    101      },
    102    },
    103  });
    104 
    105  MerinoTestUtils.server.response.body.suggestions = suggestions;
    106 });
    107 
    108 // Tests `MerinoClient`'s simplistic caching mechanism.
    109 add_task(async function cache() {
    110  MerinoTestUtils.enableClientCache(true);
    111 
    112  // Stub `MerinoClient`'s `Date.now()` so we can artificially set the date.
    113  let sandbox = sinon.createSandbox();
    114  let dateNowStub = sandbox.stub(
    115    Cu.getGlobalForObject(MerinoClient).Date,
    116    "now"
    117  );
    118  let startDateMs = Date.now();
    119  dateNowStub.returns(startDateMs);
    120 
    121  // We'll do fetches with this client and pass it this provider.
    122  let provider = "test-provider";
    123  let client = new MerinoClient("cache-test", { cachePeriodMs: 60 * 1000 });
    124 
    125  // We'll do each of these fetches in order.
    126  let fetches = [
    127    {
    128      timeOffsetMs: 0,
    129      query: "aaa",
    130      shouldCallMerino: true,
    131    },
    132    {
    133      timeOffsetMs: 30 * 1000,
    134      query: "aaa",
    135      shouldCallMerino: false,
    136    },
    137    // This call is exactly when the cache period expires, so the cache should
    138    // not be used.
    139    {
    140      timeOffsetMs: 60 * 1000,
    141      query: "aaa",
    142      shouldCallMerino: true,
    143    },
    144    {
    145      timeOffsetMs: 90 * 1000,
    146      query: "aaa",
    147      shouldCallMerino: false,
    148    },
    149    // This call is well past the previous cache period (which expired at an
    150    // offset of 120), so the cache should not be used.
    151    {
    152      timeOffsetMs: 200 * 1000,
    153      query: "aaa",
    154      shouldCallMerino: true,
    155    },
    156    {
    157      timeOffsetMs: 259 * 1000,
    158      query: "aaa",
    159      shouldCallMerino: false,
    160    },
    161    // This call is exactly when the cache period expires, so the cache should
    162    // not be used.
    163    {
    164      timeOffsetMs: 260 * 1000,
    165      query: "aaa",
    166      shouldCallMerino: true,
    167    },
    168    {
    169      timeOffsetMs: 261 * 1000,
    170      query: "aaa",
    171      shouldCallMerino: false,
    172    },
    173    // A different query creates a different request URL, so the cache should
    174    // not be used even though this request is within the current cache period.
    175    {
    176      timeOffsetMs: 262 * 1000,
    177      query: "bbb",
    178      shouldCallMerino: true,
    179    },
    180    {
    181      timeOffsetMs: 263 * 1000,
    182      query: "bbb",
    183      shouldCallMerino: false,
    184    },
    185    {
    186      timeOffsetMs: 264 * 1000,
    187      query: "aaa",
    188      shouldCallMerino: true,
    189    },
    190    {
    191      timeOffsetMs: 265 * 1000,
    192      query: "aaa",
    193      shouldCallMerino: false,
    194    },
    195  ];
    196 
    197  // Do each fetch one after another.
    198  for (let i = 0; i < fetches.length; i++) {
    199    let fetch = fetches[i];
    200    info(`Fetch ${i} start: ` + JSON.stringify(fetch));
    201 
    202    let { timeOffsetMs, query, shouldCallMerino } = fetch;
    203 
    204    // Set the date forward.
    205    dateNowStub.returns(startDateMs + timeOffsetMs);
    206 
    207    // Do the fetch.
    208    let callsByProvider = await doFetchAndGetCalls(client, {
    209      query,
    210      providers: [provider],
    211    });
    212    info(`Fetch ${i} callsByProvider: ` + JSON.stringify(callsByProvider));
    213 
    214    // Stringify each `URLSearchParams` in the `calls` array in each
    215    // `[provider, calls]` for easier comparison.
    216    let actualCalls = Object.entries(callsByProvider).map(([p, calls]) => [
    217      p,
    218      calls.map(params => {
    219        params.delete(MerinoClient.SEARCH_PARAMS.SESSION_ID);
    220        params.delete(MerinoClient.SEARCH_PARAMS.SEQUENCE_NUMBER);
    221        return params.toString();
    222      }),
    223    ]);
    224 
    225    if (!shouldCallMerino) {
    226      Assert.deepEqual(
    227        actualCalls,
    228        [],
    229        `Fetch ${i} should not have called Merino`
    230      );
    231    } else {
    232      // Build the expected params.
    233      let expectedParams = new URLSearchParams();
    234      expectedParams.set(MerinoClient.SEARCH_PARAMS.PROVIDERS, provider);
    235      expectedParams.set(MerinoClient.SEARCH_PARAMS.QUERY, query);
    236      expectedParams.sort();
    237 
    238      Assert.deepEqual(
    239        actualCalls,
    240        [[provider, [expectedParams.toString()]]],
    241        `Fetch ${i} should have called Merino`
    242      );
    243    }
    244  }
    245 
    246  sandbox.restore();
    247  MerinoTestUtils.enableClientCache(false);
    248 });
    249 
    250 async function doFetchAndGetCalls(client, fetchArgs) {
    251  let callsByProvider = {};
    252 
    253  MerinoTestUtils.server.requestHandler = req => {
    254    let params = new URLSearchParams(req.queryString);
    255    params.sort();
    256    let provider = params.get("providers");
    257    callsByProvider[provider] ||= [];
    258    callsByProvider[provider].push(params);
    259    return {
    260      body: {
    261        request_id: "request_id",
    262        suggestions: [{ foo: "bar" }],
    263      },
    264    };
    265  };
    266 
    267  await client.fetch(fetchArgs);
    268 
    269  MerinoTestUtils.server.requestHandler = null;
    270  return callsByProvider;
    271 }
    272 
    273 // Checks a 204 "No content" response.
    274 add_task(async function noContent() {
    275  Services.fog.testResetFOG();
    276 
    277  MerinoTestUtils.server.response = { status: 204 };
    278  await fetchAndCheckSuggestions({ expected: [] });
    279 
    280  Assert.equal(
    281    gClient.lastFetchStatus,
    282    "no_suggestion",
    283    "The request should have been recorded as no_suggestion"
    284  );
    285  assertTelemetry({
    286    latency: {
    287      204: {
    288        count: 1,
    289      },
    290    },
    291  });
    292 
    293  MerinoTestUtils.server.reset();
    294 });
    295 
    296 // Checks a response that's valid but also has some unexpected properties.
    297 add_task(async function unexpectedResponseProperties() {
    298  MerinoTestUtils.server.response.body.unexpectedString = "some value";
    299  MerinoTestUtils.server.response.body.unexpectedArray = ["a", "b", "c"];
    300  MerinoTestUtils.server.response.body.unexpectedObject = { foo: "bar" };
    301 
    302  await fetchAndCheckSuggestions({
    303    expected: EXPECTED_MERINO_SUGGESTIONS,
    304  });
    305 
    306  Assert.equal(
    307    gClient.lastFetchStatus,
    308    "success",
    309    "The request successfully finished"
    310  );
    311  assertTelemetry({
    312    latency: {
    313      200: {
    314        count: 1,
    315      },
    316    },
    317  });
    318 });
    319 
    320 // Checks some responses with unexpected response bodies.
    321 add_task(async function unexpectedResponseBody() {
    322  let responses = [
    323    { body: {} },
    324    { body: { bogus: [] } },
    325    { body: { suggestions: {} } },
    326    { body: { suggestions: [] } },
    327    { body: "" },
    328    { body: "bogus", contentType: "text/html" },
    329  ];
    330 
    331  for (let r of responses) {
    332    info("Testing response: " + JSON.stringify(r));
    333 
    334    MerinoTestUtils.server.response = r;
    335    await fetchAndCheckSuggestions({ expected: [] });
    336 
    337    Assert.equal(
    338      gClient.lastFetchStatus,
    339      "no_suggestion",
    340      "The request successfully finished without suggestions"
    341    );
    342    assertTelemetry({
    343      latency: {
    344        200: {
    345          count: 1,
    346        },
    347      },
    348    });
    349  }
    350 
    351  MerinoTestUtils.server.reset();
    352 });
    353 
    354 // Tests with a network error.
    355 add_task(async function networkError() {
    356  // This promise will be resolved when the client processes the network error.
    357  let responsePromise = gClient.waitForNextResponse();
    358 
    359  await MerinoTestUtils.server.withNetworkError(async () => {
    360    await fetchAndCheckSuggestions({ expected: [] });
    361  });
    362 
    363  // The client should have nulled out the timeout timer before `fetch()`
    364  // returned.
    365  Assert.strictEqual(
    366    gClient._test_timeoutTimer,
    367    null,
    368    "timeoutTimer does not exist after fetch finished"
    369  );
    370 
    371  // Wait for the client to process the network error.
    372  await responsePromise;
    373 
    374  Assert.equal(
    375    gClient.lastFetchStatus,
    376    "network_error",
    377    "The request failed with a network error"
    378  );
    379 });
    380 
    381 // Tests with an HTTP error.
    382 add_task(async function httpError() {
    383  MerinoTestUtils.server.response = { status: 500 };
    384  await fetchAndCheckSuggestions({ expected: [] });
    385 
    386  Assert.equal(
    387    gClient.lastFetchStatus,
    388    "http_error",
    389    "The request failed with an HTTP error"
    390  );
    391  assertTelemetry({
    392    latency: {
    393      500: {
    394        count: 1,
    395      },
    396    },
    397  });
    398 
    399  MerinoTestUtils.server.reset();
    400 });
    401 
    402 // Tests a client timeout.
    403 add_task(async function clientTimeout() {
    404  await doClientTimeoutTest({
    405    prefTimeoutMs: 200,
    406    responseDelayMs: 400,
    407  });
    408 });
    409 
    410 // Tests a client timeout followed by an HTTP error. Only the timeout should be
    411 // recorded.
    412 add_task(async function clientTimeoutFollowedByHTTPError() {
    413  MerinoTestUtils.server.response = { status: 500 };
    414  await doClientTimeoutTest({
    415    prefTimeoutMs: 200,
    416    responseDelayMs: 400,
    417    expectedResponseStatus: 500,
    418  });
    419 });
    420 
    421 // Tests a client timeout when a timeout value is passed to `fetch()`, which
    422 // should override the value in the `merino.timeoutMs` pref.
    423 add_task(async function timeoutPassedToFetch() {
    424  // Set up a timeline like this:
    425  //
    426  //     1ms: The timeout passed to `fetch()` elapses
    427  //   400ms: Merino returns a response
    428  // 30000ms: The timeout in the pref elapses
    429  //
    430  // The expected behavior is that the 1ms timeout is hit, the request fails
    431  // with a timeout, and Merino later returns a response. If the 1ms timeout is
    432  // not hit, then Merino will return a response before the 30000ms timeout
    433  // elapses and the request will complete successfully.
    434 
    435  await doClientTimeoutTest({
    436    prefTimeoutMs: 30000,
    437    responseDelayMs: 400,
    438    fetchArgs: { query: "search", timeoutMs: 1 },
    439  });
    440 });
    441 
    442 async function doClientTimeoutTest({
    443  prefTimeoutMs,
    444  responseDelayMs,
    445  fetchArgs = { query: "search" },
    446  expectedResponseStatus = 200,
    447 } = {}) {
    448  let originalPrefTimeoutMs = UrlbarPrefs.get("merino.timeoutMs");
    449  UrlbarPrefs.set("merino.timeoutMs", prefTimeoutMs);
    450 
    451  // Make the server return a delayed response so the client times out waiting
    452  // for it.
    453  MerinoTestUtils.server.response.delay = responseDelayMs;
    454 
    455  let responsePromise = gClient.waitForNextResponse();
    456  await fetchAndCheckSuggestions({ args: fetchArgs, expected: [] });
    457 
    458  Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out");
    459 
    460  // The client should have nulled out the timeout timer.
    461  Assert.strictEqual(
    462    gClient._test_timeoutTimer,
    463    null,
    464    "timeoutTimer does not exist after fetch finished"
    465  );
    466 
    467  // The fetch controller should still exist because the fetch should remain
    468  // ongoing.
    469  Assert.ok(
    470    gClient._test_fetchController,
    471    "fetchController still exists after fetch finished"
    472  );
    473  Assert.ok(
    474    !gClient._test_fetchController.signal.aborted,
    475    "fetchController is not aborted"
    476  );
    477 
    478  // Wait for the client to receive the response.
    479  let httpResponse = await responsePromise;
    480  Assert.ok(httpResponse, "Response was received");
    481  Assert.equal(httpResponse.status, expectedResponseStatus, "Response status");
    482 
    483  // The client should have nulled out the fetch controller.
    484  Assert.ok(!gClient._test_fetchController, "fetchController no longer exists");
    485 
    486  assertTelemetry({
    487    latency: {
    488      [expectedResponseStatus.toString()]: {
    489        count: 1,
    490        minDurationMs: responseDelayMs,
    491      },
    492    },
    493  });
    494 
    495  MerinoTestUtils.server.reset();
    496  UrlbarPrefs.set("merino.timeoutMs", originalPrefTimeoutMs);
    497 }
    498 
    499 // By design, when a fetch times out, the client allows it to finish so we can
    500 // record its latency. But when a second fetch starts before the first finishes,
    501 // the client should abort the first so that there is at most one fetch at a
    502 // time.
    503 add_task(async function newFetchAbortsPrevious() {
    504  // Make the server return a very delayed response so that it would time out
    505  // and we can start a second fetch that will abort the first fetch.
    506  MerinoTestUtils.server.response.delay =
    507    100 * UrlbarPrefs.get("merino.timeoutMs");
    508 
    509  // Do the first fetch.
    510  await fetchAndCheckSuggestions({ expected: [] });
    511 
    512  // At this point, the timeout timer has fired, causing our `fetch()` call to
    513  // return. However, the client's internal fetch should still be ongoing.
    514 
    515  Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out");
    516 
    517  // The client should have nulled out the timeout timer.
    518  Assert.strictEqual(
    519    gClient._test_timeoutTimer,
    520    null,
    521    "timeoutTimer does not exist after first fetch finished"
    522  );
    523 
    524  // The fetch controller should still exist because the fetch should remain
    525  // ongoing.
    526  Assert.ok(
    527    gClient._test_fetchController,
    528    "fetchController still exists after first fetch finished"
    529  );
    530  Assert.ok(
    531    !gClient._test_fetchController.signal.aborted,
    532    "fetchController is not aborted"
    533  );
    534 
    535  // Do the second fetch. This time don't delay the response.
    536  delete MerinoTestUtils.server.response.delay;
    537  await fetchAndCheckSuggestions({
    538    expected: EXPECTED_MERINO_SUGGESTIONS,
    539  });
    540 
    541  Assert.equal(
    542    gClient.lastFetchStatus,
    543    "success",
    544    "The request finished successfully"
    545  );
    546 
    547  // The fetch was successful, so the client should have nulled out both
    548  // properties.
    549  Assert.ok(
    550    !gClient._test_fetchController,
    551    "fetchController does not exist after second fetch finished"
    552  );
    553  Assert.strictEqual(
    554    gClient._test_timeoutTimer,
    555    null,
    556    "timeoutTimer does not exist after second fetch finished"
    557  );
    558 
    559  MerinoTestUtils.server.reset();
    560 });
    561 
    562 // The client should not include the `clientVariants` and `providers` search
    563 // params when they are not set.
    564 add_task(async function clientVariants_providers_notSet() {
    565  UrlbarPrefs.set("merino.clientVariants", "");
    566  UrlbarPrefs.set("merino.providers", "");
    567 
    568  await fetchAndCheckSuggestions({
    569    expected: EXPECTED_MERINO_SUGGESTIONS,
    570  });
    571 
    572  MerinoTestUtils.server.checkAndClearRequests([
    573    {
    574      params: {
    575        [SEARCH_PARAMS.QUERY]: "search",
    576        [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
    577      },
    578    },
    579  ]);
    580 
    581  UrlbarPrefs.clear("merino.clientVariants");
    582  UrlbarPrefs.clear("merino.providers");
    583 });
    584 
    585 // The client should include the `clientVariants` and `providers` search params
    586 // when they are set using preferences.
    587 add_task(async function clientVariants_providers_preferences() {
    588  UrlbarPrefs.set("merino.clientVariants", "green");
    589  UrlbarPrefs.set("merino.providers", "pink");
    590 
    591  await fetchAndCheckSuggestions({
    592    expected: EXPECTED_MERINO_SUGGESTIONS,
    593  });
    594 
    595  MerinoTestUtils.server.checkAndClearRequests([
    596    {
    597      params: {
    598        [SEARCH_PARAMS.QUERY]: "search",
    599        [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
    600        [SEARCH_PARAMS.CLIENT_VARIANTS]: "green",
    601        [SEARCH_PARAMS.PROVIDERS]: "pink",
    602      },
    603    },
    604  ]);
    605 
    606  UrlbarPrefs.clear("merino.clientVariants");
    607  UrlbarPrefs.clear("merino.providers");
    608 });
    609 
    610 // The client should include the `providers` search param when it's set by
    611 // passing in the `providers` argument to `fetch()`. The argument should
    612 // override the pref. This tests a single provider.
    613 add_task(async function providers_arg_single() {
    614  UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed");
    615 
    616  await fetchAndCheckSuggestions({
    617    args: { query: "search", providers: ["argShouldBeUsed"] },
    618    expected: EXPECTED_MERINO_SUGGESTIONS,
    619  });
    620 
    621  MerinoTestUtils.server.checkAndClearRequests([
    622    {
    623      params: {
    624        [SEARCH_PARAMS.QUERY]: "search",
    625        [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
    626        [SEARCH_PARAMS.PROVIDERS]: "argShouldBeUsed",
    627      },
    628    },
    629  ]);
    630 
    631  UrlbarPrefs.clear("merino.providers");
    632 });
    633 
    634 // The client should include the `providers` search param when it's set by
    635 // passing in the `providers` argument to `fetch()`. The argument should
    636 // override the pref. This tests multiple providers.
    637 add_task(async function providers_arg_many() {
    638  UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed");
    639 
    640  await fetchAndCheckSuggestions({
    641    args: { query: "search", providers: ["one", "two", "three"] },
    642    expected: EXPECTED_MERINO_SUGGESTIONS,
    643  });
    644 
    645  MerinoTestUtils.server.checkAndClearRequests([
    646    {
    647      params: {
    648        [SEARCH_PARAMS.QUERY]: "search",
    649        [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
    650        [SEARCH_PARAMS.PROVIDERS]: "one,two,three",
    651      },
    652    },
    653  ]);
    654 
    655  UrlbarPrefs.clear("merino.providers");
    656 });
    657 
    658 // The client should include the `providers` search param when it's set by
    659 // passing in the `providers` argument to `fetch()` even when it's an empty
    660 // array. The argument should override the pref.
    661 add_task(async function providers_arg_empty() {
    662  UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed");
    663 
    664  await fetchAndCheckSuggestions({
    665    args: { query: "search", providers: [] },
    666    expected: EXPECTED_MERINO_SUGGESTIONS,
    667  });
    668 
    669  MerinoTestUtils.server.checkAndClearRequests([
    670    {
    671      params: {
    672        [SEARCH_PARAMS.QUERY]: "search",
    673        [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
    674        [SEARCH_PARAMS.PROVIDERS]: "",
    675      },
    676    },
    677  ]);
    678 
    679  UrlbarPrefs.clear("merino.providers");
    680 });
    681 
    682 // Passes invalid `providers` arguments to `fetch()`.
    683 add_task(async function providers_arg_invalid() {
    684  let providersValues = ["", "nonempty", {}];
    685 
    686  for (let providers of providersValues) {
    687    info("Calling fetch() with providers: " + JSON.stringify(providers));
    688 
    689    // `Assert.throws()` doesn't seem to work with async functions...
    690    let error;
    691    try {
    692      await gClient.fetch({ providers, query: "search" });
    693    } catch (e) {
    694      error = e;
    695    }
    696    Assert.ok(error, "fetch() threw an error");
    697    Assert.equal(
    698      error.message,
    699      "providers must be an array if given",
    700      "Expected error was thrown"
    701    );
    702  }
    703 });
    704 
    705 // Tests setting the endpoint URL and query parameters via Nimbus.
    706 add_task(async function nimbus() {
    707  // Clear the endpoint pref so we know the URL is not being fetched from it.
    708  let originalEndpointURL = UrlbarPrefs.get("merino.endpointURL");
    709  UrlbarPrefs.set("merino.endpointURL", "");
    710 
    711  await UrlbarTestUtils.initNimbusFeature();
    712 
    713  // First, with the endpoint pref set to an empty string, make sure no Merino
    714  // suggestion are returned.
    715  await fetchAndCheckSuggestions({ expected: [] });
    716 
    717  // Now install an experiment that sets the endpoint and other Merino-related
    718  // variables. Make sure a suggestion is returned and the request includes the
    719  // correct query params.
    720 
    721  // `param`: The param name in the request URL
    722  // `value`: The value to use for the param
    723  // `variable`: The name of the Nimbus variable corresponding to the param
    724  let expectedParams = [
    725    {
    726      param: SEARCH_PARAMS.CLIENT_VARIANTS,
    727      value: "test-client-variants",
    728      variable: "merinoClientVariants",
    729    },
    730    {
    731      param: SEARCH_PARAMS.PROVIDERS,
    732      value: "test-providers",
    733      variable: "merinoProviders",
    734    },
    735  ];
    736 
    737  // Set up the Nimbus variable values to create the experiment with.
    738  let experimentValues = {
    739    merinoEndpointURL: MerinoTestUtils.server.url.toString(),
    740  };
    741  for (let { variable, value } of expectedParams) {
    742    experimentValues[variable] = value;
    743  }
    744 
    745  await withExperiment(experimentValues, async () => {
    746    await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS });
    747 
    748    let params = {
    749      [SEARCH_PARAMS.QUERY]: "search",
    750      [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0,
    751    };
    752    for (let { param, value } of expectedParams) {
    753      params[param] = value;
    754    }
    755    MerinoTestUtils.server.checkAndClearRequests([{ params }]);
    756  });
    757 
    758  UrlbarPrefs.set("merino.endpointURL", originalEndpointURL);
    759 });
    760 
    761 // OHTTP should be used when `client.allowOhttp` is true and the Merino OHTTP
    762 // prefs are defined.
    763 add_task(async function ohttp() {
    764  Services.fog.testResetFOG();
    765 
    766  // Stub the `ObliviousHTTP` functions.
    767  let sandbox = sinon.createSandbox();
    768  sandbox.stub(ObliviousHTTP, "getOHTTPConfig").resolves({});
    769  sandbox.stub(ObliviousHTTP, "ohttpRequest").resolves({
    770    status: 200,
    771    json: async () => [],
    772  });
    773 
    774  // Try all combinations of URLs and `allowOhttp`. OHTTP should be used only
    775  // when both URLs are defined and `allowOhttp` is true.
    776  for (let configUrl of ["", "https://example.com/config"]) {
    777    for (let relayUrl of ["", "https://example.com/relay"]) {
    778      for (let allowOhttp of [false, true]) {
    779        UrlbarPrefs.set("merino.ohttpConfigURL", configUrl);
    780        UrlbarPrefs.set("merino.ohttpRelayURL", relayUrl);
    781 
    782        let client = new MerinoClient("ohttp client", { allowOhttp });
    783        await client.fetch({ query: "test" });
    784 
    785        let shouldUseOhttp = configUrl && relayUrl && allowOhttp;
    786        let expectedCallCount = shouldUseOhttp ? 1 : 0;
    787 
    788        Assert.equal(
    789          ObliviousHTTP.getOHTTPConfig.callCount,
    790          expectedCallCount,
    791          "getOHTTPConfig should have been called the expected number of times"
    792        );
    793        Assert.equal(
    794          ObliviousHTTP.ohttpRequest.callCount,
    795          expectedCallCount,
    796          "ohttpRequest should have been called the expected number of times"
    797        );
    798 
    799        let expectedLatencyLabel = "200";
    800        if (shouldUseOhttp) {
    801          expectedLatencyLabel += "_ohttp";
    802        }
    803        assertTelemetry({
    804          latency: {
    805            [expectedLatencyLabel]: {
    806              count: 1,
    807            },
    808          },
    809        });
    810      }
    811    }
    812  }
    813 
    814  sandbox.restore();
    815 });
    816 
    817 // If the client uses OHTTP but can't get an OHTTP config, it should return an
    818 // empty suggestions array.
    819 add_task(async function ohttp_noConfig() {
    820  // Stub the `ObliviousHTTP` functions so that `getOHTTPConfig` returns null.
    821  let sandbox = sinon.createSandbox();
    822  sandbox.stub(ObliviousHTTP, "getOHTTPConfig").resolves(null);
    823  sandbox.stub(ObliviousHTTP, "ohttpRequest").resolves({
    824    status: 200,
    825    json: async () => [],
    826  });
    827 
    828  UrlbarPrefs.set("merino.ohttpConfigURL", "https://example.com/config");
    829  UrlbarPrefs.set("merino.ohttpRelayURL", "https://example.com/relay");
    830 
    831  let client = new MerinoClient("ohttp client", { allowOhttp: true });
    832  let suggestions = await client.fetch({ query: "test" });
    833 
    834  Assert.equal(
    835    ObliviousHTTP.getOHTTPConfig.callCount,
    836    1,
    837    "getOHTTPConfig should have been called once"
    838  );
    839  Assert.equal(
    840    ObliviousHTTP.ohttpRequest.callCount,
    841    0,
    842    "ohttpRequest should not have been called"
    843  );
    844 
    845  Assert.deepEqual(
    846    suggestions,
    847    [],
    848    "The client should have returned an empty suggestions array"
    849  );
    850 
    851  sandbox.restore();
    852 });
    853 
    854 async function fetchAndCheckSuggestions({
    855  expected,
    856  args = {
    857    query: "search",
    858  },
    859 }) {
    860  let actual = await gClient.fetch(args);
    861  Assert.deepEqual(actual, expected, "Expected suggestions");
    862  gClient.resetSession();
    863 }
    864 
    865 async function withExperiment(values, callback) {
    866  const doExperimentCleanup = await NimbusTestUtils.enrollWithFeatureConfig(
    867    {
    868      featureId: NimbusFeatures.urlbar.featureId,
    869      value: {
    870        enabled: true,
    871        ...values,
    872      },
    873    },
    874    {
    875      slug: "mock-experiment",
    876      branchSlug: "treatment",
    877    }
    878  );
    879  await callback();
    880  await doExperimentCleanup();
    881 }
    882 
    883 function assertTelemetry({ latency, reset = true }) {
    884  for (let [label, expected] of Object.entries(latency)) {
    885    let metric = Glean.urlbarMerino.latencyByResponseStatus[label];
    886    Assert.ok(metric, "A metric should exist for the expected label: " + label);
    887    if (!metric) {
    888      continue;
    889    }
    890 
    891    let actual = metric.testGetValue();
    892    Assert.ok(actual, "The metric should have a value for label: " + label);
    893    if (!actual) {
    894      continue;
    895    }
    896 
    897    info(
    898      `Actual value for latency metric label '${label}': ` +
    899        JSON.stringify(actual)
    900    );
    901 
    902    let { count, minDurationMs = 0 } = expected;
    903 
    904    Assert.strictEqual(actual.count, count, "count should be correct");
    905 
    906    Assert.greater(actual.sum, 0, "sum should be > 0");
    907    for (let bucket of Object.keys(actual.values)) {
    908      Assert.greater(parseInt(bucket), 0, "bucket should be > 0");
    909    }
    910 
    911    Assert.greaterOrEqual(
    912      actual.sum,
    913      minDurationMs,
    914      "sum should be >= expected min duration"
    915    );
    916    for (let bucket of Object.keys(actual.values)) {
    917      Assert.greaterOrEqual(
    918        parseInt(bucket),
    919        minDurationMs,
    920        "bucket should be >= expected min duration"
    921      );
    922    }
    923  }
    924 
    925  if (reset) {
    926    Services.fog.testResetFOG();
    927  }
    928 }