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();