tor-browser

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

MerinoTestUtils.sys.mjs (20410B)


      1 /* Any copyright is dedicated to the Public Domain.
      2   http://creativecommons.org/publicdomain/zero/1.0/ */
      3 
      4 const lazy = {};
      5 
      6 ChromeUtils.defineESModuleGetters(lazy, {
      7  MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs",
      8  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
      9 });
     10 
     11 import { HttpServer } from "resource://testing-common/httpd.sys.mjs";
     12 
     13 /**
     14 * @import {Assert} from "resource://testing-common/Assert.sys.mjs"
     15 */
     16 
     17 // The following properties and methods are copied from the test scope to the
     18 // test utils object so they can be easily accessed. Be careful about assuming a
     19 // particular property will be defined because depending on the scope -- browser
     20 // test or xpcshell test -- some may not be.
     21 const TEST_SCOPE_PROPERTIES = [
     22  "Assert",
     23  "EventUtils",
     24  "info",
     25  "registerCleanupFunction",
     26 ];
     27 
     28 const SEARCH_PARAMS = {
     29  CLIENT_VARIANTS: "client_variants",
     30  PROVIDERS: "providers",
     31  QUERY: "q",
     32  SEQUENCE_NUMBER: "seq",
     33  SESSION_ID: "sid",
     34 };
     35 
     36 const REQUIRED_SEARCH_PARAMS = [
     37  SEARCH_PARAMS.QUERY,
     38  SEARCH_PARAMS.SEQUENCE_NUMBER,
     39  SEARCH_PARAMS.SESSION_ID,
     40 ];
     41 
     42 // We set the client timeout to a large value to avoid intermittent failures in
     43 // CI, especially TV tests, where the Merino fetch unexpectedly doesn't finish
     44 // before the default timeout.
     45 const CLIENT_TIMEOUT_MS = 2000;
     46 
     47 const WEATHER_SUGGESTION = {
     48  title: "Weather for San Francisco",
     49  url: "https://example.com/weather",
     50  provider: "accuweather",
     51  is_sponsored: false,
     52  score: 0.2,
     53  icon: null,
     54  city_name: "San Francisco",
     55  region_code: "CA",
     56  current_conditions: {
     57    url: "https://example.com/weather-current-conditions",
     58    summary: "Mostly cloudy",
     59    icon_id: 6,
     60    temperature: { c: 15.5, f: 60.0 },
     61  },
     62  forecast: {
     63    url: "https://example.com/weather-forecast",
     64    summary: "Pleasant Saturday",
     65    high: { c: 21.1, f: 70.0 },
     66    low: { c: 13.9, f: 57.0 },
     67  },
     68 };
     69 
     70 /** @typedef {() => Promise<void>} cleanupFunctionType */
     71 
     72 /**
     73 * Test utils for Merino.
     74 */
     75 class _MerinoTestUtils {
     76  /** @type {Assert} */
     77  Assert = undefined;
     78 
     79  /** @type {object} */
     80  EventUtils = undefined;
     81 
     82  /** @type {(message:string) => void} */
     83  info = undefined;
     84 
     85  /** @type {(cleanupFn: cleanupFunctionType) => void} */
     86  registerCleanupFunction = undefined;
     87 
     88  /**
     89   * Initializes the utils. Also disables caching in `MerinoClient` since
     90   * caching typically makes it harder to write tests.
     91   *
     92   * @param {object} scope
     93   *   The global JS scope where tests are being run. This allows the instance
     94   *   to access test helpers like `Assert` that are available in the scope.
     95   */
     96  init(scope) {
     97    if (!scope) {
     98      throw new Error("MerinoTestUtils.init() must be called with a scope");
     99    }
    100 
    101    this.#initDepth++;
    102    scope.info?.("MerinoTestUtils init: Depth is now " + this.#initDepth);
    103 
    104    for (let p of TEST_SCOPE_PROPERTIES) {
    105      this[p] = scope[p];
    106    }
    107    // If you add other properties to `this`, null them in `uninit()`.
    108 
    109    if (!this.#server) {
    110      this.#server = new MockMerinoServer(scope);
    111      this.enableClientCache(false);
    112    }
    113    lazy.UrlbarPrefs.set("merino.timeoutMs", CLIENT_TIMEOUT_MS);
    114    scope.registerCleanupFunction?.(() => {
    115      scope.info?.("MerinoTestUtils cleanup function");
    116      this.uninit();
    117    });
    118  }
    119 
    120  /**
    121   * Uninitializes the utils. If they were created with a test scope that
    122   * defines `registerCleanupFunction()`, you don't need to call this yourself
    123   * because it will automatically be called as a cleanup function. Otherwise
    124   * you'll need to call this.
    125   */
    126  uninit() {
    127    this.#initDepth--;
    128    this.info?.("MerinoTestUtils uninit: Depth is now " + this.#initDepth);
    129 
    130    if (this.#initDepth) {
    131      this.info?.("MerinoTestUtils uninit: Bailing because depth > 0");
    132      return;
    133    }
    134    this.info?.("MerinoTestUtils uninit: Now uninitializing");
    135 
    136    for (let p of TEST_SCOPE_PROPERTIES) {
    137      this[p] = null;
    138    }
    139    this.#server.uninit();
    140    this.#server = null;
    141    lazy.UrlbarPrefs.clear("merino.timeoutMs");
    142  }
    143 
    144  /**
    145   * @returns {object}
    146   *   The names of URL search params.
    147   */
    148  get SEARCH_PARAMS() {
    149    return SEARCH_PARAMS;
    150  }
    151 
    152  /**
    153   * @returns {object}
    154   *   The inner `geolocation` object inside the mock geolocation suggestion.
    155   *   This returns a new object so callers are free to modify it.
    156   */
    157  get GEOLOCATION() {
    158    return this.GEOLOCATION_SUGGESTION.custom_details.geolocation;
    159  }
    160 
    161  /**
    162   * @returns {object}
    163   *   Mock geolocation suggestion as returned by Merino. This returns a new
    164   *   object so callers are free to modify it.
    165   */
    166  get GEOLOCATION_SUGGESTION() {
    167    return {
    168      provider: "geolocation",
    169      title: "",
    170      url: "https://merino.services.mozilla.com/",
    171      is_sponsored: false,
    172      score: 0,
    173      custom_details: {
    174        geolocation: {
    175          country: "Japan",
    176          country_code: "JP",
    177          region: "Kanagawa",
    178          region_code: "Kanagawa",
    179          city: "Yokohama",
    180          location: {
    181            latitude: 35.444167,
    182            longitude: 139.638056,
    183            radius: 5,
    184          },
    185        },
    186      },
    187    };
    188  }
    189 
    190  /**
    191   * @returns {object}
    192   *   A mock weather suggestion.
    193   */
    194  get WEATHER_SUGGESTION() {
    195    return WEATHER_SUGGESTION;
    196  }
    197 
    198  /**
    199   * @returns {MockMerinoServer}
    200   *   The mock Merino server. The server isn't started until its `start()`
    201   *   method is called.
    202   */
    203  get server() {
    204    return this.#server;
    205  }
    206 
    207  /**
    208   * Initializes the quick suggest weather feature and mock Merino server.
    209   */
    210  async initWeather() {
    211    this.info("MockMerinoServer initializing weather, starting server");
    212    await this.server.start();
    213    this.info("MockMerinoServer initializing weather, server now started");
    214    this.server.response.body.suggestions = [WEATHER_SUGGESTION];
    215 
    216    // Enabling weather will trigger a fetch. Queue another fetch and await it
    217    // so no fetches are ongoing when this function returns.
    218    this.info("MockMerinoServer initializing weather, setting prefs");
    219    lazy.UrlbarPrefs.set("weather.featureGate", true);
    220    lazy.UrlbarPrefs.set("suggest.weather", true);
    221    this.info("MockMerinoServer initializing weather, done setting prefs");
    222 
    223    this.registerCleanupFunction?.(async () => {
    224      lazy.UrlbarPrefs.clear("weather.featureGate");
    225      lazy.UrlbarPrefs.clear("suggest.weather");
    226    });
    227  }
    228 
    229  /**
    230   * Initializes the mock Merino geolocation server.
    231   */
    232  async initGeolocation() {
    233    await this.server.start();
    234    this.server.response = this.server.makeDefaultResponse();
    235    this.server.response.body.suggestions = [this.GEOLOCATION_SUGGESTION];
    236  }
    237 
    238  /**
    239   * Enables or disables caching in `MerinoClient`.
    240   *
    241   * @param {boolean} enable
    242   *   Whether caching should be enabled.
    243   */
    244  enableClientCache(enable) {
    245    lazy.MerinoClient._test_disableCache = !enable;
    246  }
    247 
    248  #initDepth = 0;
    249  #server = null;
    250 }
    251 
    252 /**
    253 * A mock Merino server with useful helper methods.
    254 */
    255 class MockMerinoServer {
    256  /** @type {Assert} */
    257  Assert = undefined;
    258 
    259  /** @type {object} */
    260  EventUtils = undefined;
    261 
    262  /** @type {(message:string) => void} */
    263  info = undefined;
    264 
    265  /** @type {(cleanupFn: cleanupFunctionType) => void} */
    266  registerCleanupFunction = undefined;
    267 
    268  /**
    269   * Until `start()` is called the server isn't started and `this.url` is null.
    270   *
    271   * @param {object} scope
    272   *   The global JS scope where tests are being run. This allows the instance
    273   *   to access test helpers like `Assert` that are available in the scope.
    274   */
    275  constructor(scope) {
    276    scope.info?.("MockMerinoServer constructor");
    277 
    278    for (let p of TEST_SCOPE_PROPERTIES) {
    279      this[p] = scope[p];
    280    }
    281 
    282    let path = "/merino";
    283    this.#httpServer = new HttpServer();
    284    this.#httpServer.registerPathHandler(path, (req, resp) =>
    285      this.#handleRequest(req, resp)
    286    );
    287    this.#baseURL = new URL("http://localhost/");
    288    this.#baseURL.pathname = path;
    289 
    290    this.reset();
    291  }
    292 
    293  /**
    294   * Uninitializes the server.
    295   */
    296  uninit() {
    297    this.info?.("MockMerinoServer uninit");
    298    for (let p of TEST_SCOPE_PROPERTIES) {
    299      this[p] = null;
    300    }
    301  }
    302 
    303  /**
    304   * @returns {nsIHttpServer}
    305   *   The underlying HTTP server.
    306   */
    307  get httpServer() {
    308    return this.#httpServer;
    309  }
    310 
    311  /**
    312   * @returns {URL}
    313   *   The server's endpoint URL or null if the server isn't running.
    314   */
    315  get url() {
    316    return this.#url;
    317  }
    318 
    319  /**
    320   * @returns {Array}
    321   *   Array of received nsIHttpRequest objects. Requests are continually
    322   *   collected, and the list can be cleared with `reset()`.
    323   */
    324  get requests() {
    325    return this.#requests;
    326  }
    327 
    328  /**
    329   * @returns {object}
    330   *   An object that describes the response that the server will return. Can be
    331   *   modified or set to a different object to change the response. Can be
    332   *   reset to the default reponse by calling `reset()`. For details see
    333   *   `makeDefaultResponse()` and `#handleRequest()`. In summary:
    334   *
    335   *   {
    336   *     status,
    337   *     contentType,
    338   *     delay,
    339   *     body: {
    340   *       request_id,
    341   *       suggestions,
    342   *     },
    343   *   }
    344   */
    345  get response() {
    346    return this.#response;
    347  }
    348  set response(value) {
    349    this.#response = value;
    350    this.#requestHandler = null;
    351  }
    352 
    353  /**
    354   * If you need more control over responses than is allowed by setting
    355   * `server.response`, you can use this to register a callback that will be
    356   * called on each request. To unregister the callback, pass null or set
    357   * `server.response`.
    358   *
    359   * @param {Function | null} callback
    360   *   This function will be called on each request and passed the
    361   *   `nsIHttpRequest`. It should return a response object as described by the
    362   *   `server.response` jsdoc.
    363   */
    364  set requestHandler(callback) {
    365    this.#requestHandler = callback;
    366  }
    367 
    368  /**
    369   * Starts the server and sets `this.url`. If the server was created with a
    370   * test scope that defines `registerCleanupFunction()`, you don't need to call
    371   * `stop()` yourself because it will automatically be called as a cleanup
    372   * function. Otherwise you'll need to call `stop()`.
    373   */
    374  async start() {
    375    if (this.#url) {
    376      return;
    377    }
    378 
    379    this.info("MockMerinoServer starting");
    380 
    381    this.#httpServer.start(-1);
    382    this.#url = new URL(this.#baseURL);
    383    this.#url.port = this.#httpServer.identity.primaryPort;
    384 
    385    this._originalEndpointURL = lazy.UrlbarPrefs.get("merino.endpointURL");
    386    lazy.UrlbarPrefs.set("merino.endpointURL", this.#url.toString());
    387 
    388    this.registerCleanupFunction?.(() => this.stop());
    389 
    390    // Wait for the server to actually start serving. In TV tests, where the
    391    // server is created over and over again, sometimes it doesn't seem to be
    392    // ready after being recreated even after `#httpServer.start()` is called.
    393    this.info("MockMerinoServer waiting to start serving...");
    394    this.reset();
    395    let suggestion;
    396    while (!suggestion) {
    397      let response = await fetch(this.#url);
    398      /** @type {{suggestions: string[]}} */
    399      let body = /** @type {any} */ (await response?.json());
    400      suggestion = body?.suggestions?.[0];
    401    }
    402    this.reset();
    403    this.info("MockMerinoServer is now serving");
    404  }
    405 
    406  /**
    407   * Stops the server and cleans up other state.
    408   */
    409  async stop() {
    410    if (!this.#url) {
    411      return;
    412    }
    413 
    414    // `uninit()` may have already been called by this point and removed
    415    // `this.info()`, so don't assume it's defined.
    416    this.info?.("MockMerinoServer stopping");
    417 
    418    // Cancel delayed-response timers and resolve their promises. Otherwise, if
    419    // a test awaits this method before finishing, it will hang until the timers
    420    // fire and allow the server to send the responses.
    421    this.#cancelDelayedResponses();
    422 
    423    await this.#httpServer.stop();
    424    this.#url = null;
    425    lazy.UrlbarPrefs.set("merino.endpointURL", this._originalEndpointURL);
    426 
    427    this.info?.("MockMerinoServer is now stopped");
    428  }
    429 
    430  /**
    431   * Returns a new object that describes the default response the server will
    432   * return.
    433   *
    434   * @returns {object}
    435   */
    436  makeDefaultResponse() {
    437    return {
    438      status: 200,
    439      contentType: "application/json",
    440      body: {
    441        request_id: "request_id",
    442        suggestions: [
    443          {
    444            provider: "adm",
    445            full_keyword: "amp",
    446            title: "Amp Suggestion",
    447            url: "https://example.com/amp",
    448            icon: null,
    449            impression_url: "https://example.com/amp-impression",
    450            click_url: "https://example.com/amp-click",
    451            block_id: 1,
    452            advertiser: "Amp",
    453            iab_category: "22 - Shopping",
    454            is_sponsored: true,
    455            score: 1,
    456          },
    457        ],
    458      },
    459    };
    460  }
    461 
    462  /**
    463   * Clears the received requests and sets the response to the default.
    464   */
    465  reset() {
    466    this.#requests = [];
    467    this.response = this.makeDefaultResponse();
    468    this.#cancelDelayedResponses();
    469  }
    470 
    471  /**
    472   * Asserts a given list of requests has been received. Clears the list of
    473   * received requests before returning.
    474   *
    475   * @param {Array} expected
    476   *   The expected requests. Each item should be an object: `{ params }`
    477   */
    478  checkAndClearRequests(expected) {
    479    let actual = this.requests.map(req => {
    480      let params = new URLSearchParams(req.queryString);
    481      return { params: Object.fromEntries(params) };
    482    });
    483 
    484    this.info("Checking requests");
    485    this.info("actual: " + JSON.stringify(actual));
    486    this.info("expect: " + JSON.stringify(expected));
    487 
    488    // Check the request count.
    489    this.Assert.equal(actual.length, expected.length, "Expected request count");
    490    if (actual.length != expected.length) {
    491      return;
    492    }
    493 
    494    // Check each request.
    495    for (let i = 0; i < actual.length; i++) {
    496      let a = actual[i];
    497      let e = expected[i];
    498      this.info("Checking requests at index " + i);
    499      this.info("actual: " + JSON.stringify(a));
    500      this.info("expect: " + JSON.stringify(e));
    501 
    502      // Check required search params.
    503      for (let p of REQUIRED_SEARCH_PARAMS) {
    504        this.Assert.ok(
    505          a.params.hasOwnProperty(p),
    506          "Required param is present in actual request: " + p
    507        );
    508        if (p != SEARCH_PARAMS.SESSION_ID) {
    509          this.Assert.ok(
    510            e.params.hasOwnProperty(p),
    511            "Required param is present in expected request: " + p
    512          );
    513        }
    514      }
    515 
    516      // If the expected request doesn't include a session ID, then:
    517      if (!e.params.hasOwnProperty(SEARCH_PARAMS.SESSION_ID)) {
    518        if (e.params[SEARCH_PARAMS.SEQUENCE_NUMBER] == 0 || i == 0) {
    519          // If its sequence number is zero, then copy the actual request's
    520          // sequence number to the expected request. As a convenience, do the
    521          // same if this is the first request.
    522          e.params[SEARCH_PARAMS.SESSION_ID] =
    523            a.params[SEARCH_PARAMS.SESSION_ID];
    524        } else {
    525          // Otherwise this is not the first request in the session and
    526          // therefore the session ID should be the same as the ID in the
    527          // previous expected request.
    528          e.params[SEARCH_PARAMS.SESSION_ID] =
    529            expected[i - 1].params[SEARCH_PARAMS.SESSION_ID];
    530        }
    531      }
    532 
    533      this.Assert.deepEqual(a, e, "Expected request at index " + i);
    534 
    535      let actualSessionID = a.params[SEARCH_PARAMS.SESSION_ID];
    536      this.Assert.ok(actualSessionID, "Session ID exists");
    537      this.Assert.ok(
    538        /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(actualSessionID),
    539        "Session ID is a UUID"
    540      );
    541    }
    542 
    543    this.#requests = [];
    544  }
    545 
    546  /**
    547   * Temporarily creates the conditions for a network error. Any Merino fetches
    548   * that occur during the callback will fail with a network error.
    549   *
    550   * @param {Function} callback
    551   *   Callback function.
    552   */
    553  async withNetworkError(callback) {
    554    // Set the endpoint to a valid, unreachable URL.
    555    let originalURL = lazy.UrlbarPrefs.get("merino.endpointURL");
    556    lazy.UrlbarPrefs.set(
    557      "merino.endpointURL",
    558      "http://localhost/valid-but-unreachable-url"
    559    );
    560 
    561    // Set the timeout high enough that the network error exception will happen
    562    // first. On Mac and Linux the fetch naturally times out fairly quickly but
    563    // on Windows it seems to take 5s, so set our artificial timeout to 10s.
    564    let originalTimeout = lazy.UrlbarPrefs.get("merino.timeoutMs");
    565    lazy.UrlbarPrefs.set("merino.timeoutMs", 10000);
    566 
    567    await callback();
    568 
    569    lazy.UrlbarPrefs.set("merino.endpointURL", originalURL);
    570    lazy.UrlbarPrefs.set("merino.timeoutMs", originalTimeout);
    571  }
    572 
    573  /**
    574   * Returns a promise that will resolve when the next request is received.
    575   *
    576   * @returns {Promise}
    577   */
    578  waitForNextRequest() {
    579    if (!this.#nextRequestDeferred) {
    580      this.#nextRequestDeferred = Promise.withResolvers();
    581    }
    582    return this.#nextRequestDeferred.promise;
    583  }
    584 
    585  /**
    586   * nsIHttpServer request handler.
    587   *
    588   * @param {nsIHttpRequest} httpRequest
    589   *   Request.
    590   * @param {nsIHttpResponse} httpResponse
    591   *   Response.
    592   */
    593  #handleRequest(httpRequest, httpResponse) {
    594    this.info(
    595      "MockMerinoServer received request with query string: " +
    596        JSON.stringify(httpRequest.queryString)
    597    );
    598 
    599    // Add the request to the list of received requests.
    600    this.#requests.push(httpRequest);
    601 
    602    // Resolve promises waiting on the next request.
    603    this.#nextRequestDeferred?.resolve();
    604    this.#nextRequestDeferred = null;
    605 
    606    // Now set up and finish the response.
    607    httpResponse.processAsync();
    608 
    609    let response = this.#requestHandler?.(httpRequest) || this.response;
    610 
    611    this.info(
    612      "MockMerinoServer replying with response: " + JSON.stringify(response)
    613    );
    614 
    615    let finishResponse = () => {
    616      let status = response.status || 200;
    617      httpResponse.setStatusLine("", status, status);
    618 
    619      let contentType = response.contentType || "application/json";
    620      httpResponse.setHeader("Content-Type", contentType, false);
    621 
    622      if (typeof response.body == "string") {
    623        httpResponse.write(response.body);
    624      } else if (response.body) {
    625        httpResponse.write(JSON.stringify(response.body));
    626      }
    627 
    628      httpResponse.finish();
    629    };
    630 
    631    if (typeof response.delay != "number") {
    632      finishResponse();
    633      return;
    634    }
    635 
    636    // Set up a timer to wait until the delay elapses. Since we called
    637    // `httpResponse.processAsync()`, we need to be careful to always finish the
    638    // response, even if the timer is canceled. Otherwise the server will hang
    639    // when we try to stop it at the end of the test. When an `nsITimer` is
    640    // canceled, its callback is *not* called. Therefore we set up a race
    641    // between the timer's callback and a deferred promise. If the timer is
    642    // canceled, resolving the deferred promise will resolve the race, and the
    643    // response can then be finished.
    644 
    645    let delayedResponseID = this.#nextDelayedResponseID++;
    646    this.info(
    647      "MockMerinoServer delaying response: " +
    648        JSON.stringify({ delayedResponseID, delay: response.delay })
    649    );
    650 
    651    let deferred = Promise.withResolvers();
    652    let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    653    let record = { timer, resolve: deferred.resolve };
    654    this.#delayedResponseRecords.add(record);
    655 
    656    // Don't await this promise.
    657    Promise.race([
    658      deferred.promise,
    659      new Promise(resolve => {
    660        timer.initWithCallback(
    661          resolve,
    662          response.delay,
    663          Ci.nsITimer.TYPE_ONE_SHOT
    664        );
    665      }),
    666    ]).then(() => {
    667      this.info(
    668        "MockMerinoServer done delaying response: " +
    669          JSON.stringify({ delayedResponseID })
    670      );
    671      deferred.resolve();
    672      this.#delayedResponseRecords.delete(record);
    673      finishResponse();
    674    });
    675  }
    676 
    677  /**
    678   * Cancels the timers for delayed responses and resolves their promises.
    679   */
    680  #cancelDelayedResponses() {
    681    for (let { timer, resolve } of this.#delayedResponseRecords) {
    682      timer.cancel();
    683      resolve();
    684    }
    685    this.#delayedResponseRecords.clear();
    686  }
    687 
    688  #httpServer = null;
    689  #url = null;
    690  #baseURL = null;
    691  #response = null;
    692  #requestHandler = null;
    693  #requests = [];
    694  #nextRequestDeferred = null;
    695  #nextDelayedResponseID = 0;
    696  #delayedResponseRecords = new Set();
    697 }
    698 
    699 export var MerinoTestUtils = new _MerinoTestUtils();