/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ // Test for MerinoClient. "use strict"; ChromeUtils.defineESModuleGetters(this, { ExperimentFakes: "resource://testing-common/NimbusTestUtils.sys.mjs", MerinoClient: "resource:///modules/MerinoClient.sys.mjs", NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", }); // Set the `merino.timeoutMs` pref to a large value so that the client will not // inadvertently time out during fetches. This is especially important on CI and // when running this test in verify mode. Tasks that specifically test timeouts // may need to set a more reasonable value for their duration. const TEST_TIMEOUT_MS = 30000; // The expected suggestion objects returned from `MerinoClient.fetch()`. const EXPECTED_MERINO_SUGGESTIONS = []; const { SEARCH_PARAMS } = MerinoClient; let gClient; add_setup(async function init() { UrlbarPrefs.set("merino.timeoutMs", TEST_TIMEOUT_MS); registerCleanupFunction(() => { UrlbarPrefs.clear("merino.timeoutMs"); }); gClient = new MerinoClient(); await MerinoTestUtils.server.start(); for (let suggestion of MerinoTestUtils.server.response.body.suggestions) { EXPECTED_MERINO_SUGGESTIONS.push({ ...suggestion, request_id: MerinoTestUtils.server.response.body.request_id, source: "merino", }); } }); // Checks client names. add_task(async function name() { Assert.equal( gClient.name, "anonymous", "gClient name is 'anonymous' since it wasn't given a name" ); let client = new MerinoClient("New client"); Assert.equal(client.name, "New client", "newClient name is correct"); }); // Does a successful fetch. add_task(async function success() { let histograms = MerinoTestUtils.getAndClearHistograms(); await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS, }); Assert.equal( gClient.lastFetchStatus, "success", "The request successfully finished" ); MerinoTestUtils.checkAndClearHistograms({ histograms, response: "success", latencyRecorded: true, client: gClient, }); }); // Does a successful fetch that doesn't return any suggestions. add_task(async function noSuggestions() { let { suggestions } = MerinoTestUtils.server.response.body; MerinoTestUtils.server.response.body.suggestions = []; let histograms = MerinoTestUtils.getAndClearHistograms(); await fetchAndCheckSuggestions({ expected: [], }); Assert.equal( gClient.lastFetchStatus, "no_suggestion", "The request successfully finished without suggestions" ); MerinoTestUtils.checkAndClearHistograms({ histograms, response: "no_suggestion", latencyRecorded: true, client: gClient, }); MerinoTestUtils.server.response.body.suggestions = suggestions; }); // Checks a response that's valid but also has some unexpected properties. add_task(async function unexpectedResponseProperties() { let histograms = MerinoTestUtils.getAndClearHistograms(); MerinoTestUtils.server.response.body.unexpectedString = "some value"; MerinoTestUtils.server.response.body.unexpectedArray = ["a", "b", "c"]; MerinoTestUtils.server.response.body.unexpectedObject = { foo: "bar" }; await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS, }); Assert.equal( gClient.lastFetchStatus, "success", "The request successfully finished" ); MerinoTestUtils.checkAndClearHistograms({ histograms, response: "success", latencyRecorded: true, client: gClient, }); }); // Checks some responses with unexpected response bodies. add_task(async function unexpectedResponseBody() { let histograms = MerinoTestUtils.getAndClearHistograms(); let responses = [ { body: {} }, { body: { bogus: [] } }, { body: { suggestions: {} } }, { body: { suggestions: [] } }, { body: "" }, { body: "bogus", contentType: "text/html" }, ]; for (let r of responses) { info("Testing response: " + JSON.stringify(r)); MerinoTestUtils.server.response = r; await fetchAndCheckSuggestions({ expected: [] }); Assert.equal( gClient.lastFetchStatus, "no_suggestion", "The request successfully finished without suggestions" ); MerinoTestUtils.checkAndClearHistograms({ histograms, response: "no_suggestion", latencyRecorded: true, client: gClient, }); } MerinoTestUtils.server.reset(); }); // Tests with a network error. add_task(async function networkError() { let histograms = MerinoTestUtils.getAndClearHistograms(); // This promise will be resolved when the client processes the network error. let responsePromise = gClient.waitForNextResponse(); await MerinoTestUtils.server.withNetworkError(async () => { await fetchAndCheckSuggestions({ expected: [] }); }); // The client should have nulled out the timeout timer before `fetch()` // returned. Assert.strictEqual( gClient._test_timeoutTimer, null, "timeoutTimer does not exist after fetch finished" ); // Wait for the client to process the network error. await responsePromise; Assert.equal( gClient.lastFetchStatus, "network_error", "The request failed with a network error" ); MerinoTestUtils.checkAndClearHistograms({ histograms, response: "network_error", latencyRecorded: false, client: gClient, }); }); // Tests with an HTTP error. add_task(async function httpError() { let histograms = MerinoTestUtils.getAndClearHistograms(); MerinoTestUtils.server.response = { status: 500 }; await fetchAndCheckSuggestions({ expected: [] }); Assert.equal( gClient.lastFetchStatus, "http_error", "The request failed with an HTTP error" ); MerinoTestUtils.checkAndClearHistograms({ histograms, response: "http_error", latencyRecorded: true, client: gClient, }); MerinoTestUtils.server.reset(); }); // Tests a client timeout. add_task(async function clientTimeout() { await doClientTimeoutTest({ prefTimeoutMs: 200, responseDelayMs: 400, }); }); // Tests a client timeout followed by an HTTP error. Only the timeout should be // recorded. add_task(async function clientTimeoutFollowedByHTTPError() { MerinoTestUtils.server.response = { status: 500 }; await doClientTimeoutTest({ prefTimeoutMs: 200, responseDelayMs: 400, expectedResponseStatus: 500, }); }); // Tests a client timeout when a timeout value is passed to `fetch()`, which // should override the value in the `merino.timeoutMs` pref. add_task(async function timeoutPassedToFetch() { // Set up a timeline like this: // // 1ms: The timeout passed to `fetch()` elapses // 400ms: Merino returns a response // 30000ms: The timeout in the pref elapses // // The expected behavior is that the 1ms timeout is hit, the request fails // with a timeout, and Merino later returns a response. If the 1ms timeout is // not hit, then Merino will return a response before the 30000ms timeout // elapses and the request will complete successfully. await doClientTimeoutTest({ prefTimeoutMs: 30000, responseDelayMs: 400, fetchArgs: { query: "search", timeoutMs: 1 }, }); }); async function doClientTimeoutTest({ prefTimeoutMs, responseDelayMs, fetchArgs = { query: "search" }, expectedResponseStatus = 200, } = {}) { let histograms = MerinoTestUtils.getAndClearHistograms(); let originalPrefTimeoutMs = UrlbarPrefs.get("merino.timeoutMs"); UrlbarPrefs.set("merino.timeoutMs", prefTimeoutMs); // Make the server return a delayed response so the client times out waiting // for it. MerinoTestUtils.server.response.delay = responseDelayMs; let responsePromise = gClient.waitForNextResponse(); await fetchAndCheckSuggestions({ args: fetchArgs, expected: [] }); Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out"); // The client should have nulled out the timeout timer. Assert.strictEqual( gClient._test_timeoutTimer, null, "timeoutTimer does not exist after fetch finished" ); // The fetch controller should still exist because the fetch should remain // ongoing. Assert.ok( gClient._test_fetchController, "fetchController still exists after fetch finished" ); Assert.ok( !gClient._test_fetchController.signal.aborted, "fetchController is not aborted" ); // The latency histogram should not be updated since the response has not been // received. MerinoTestUtils.checkAndClearHistograms({ histograms, response: "timeout", latencyRecorded: false, latencyStopwatchRunning: true, client: gClient, }); // Wait for the client to receive the response. let httpResponse = await responsePromise; Assert.ok(httpResponse, "Response was received"); Assert.equal(httpResponse.status, expectedResponseStatus, "Response status"); // The client should have nulled out the fetch controller. Assert.ok(!gClient._test_fetchController, "fetchController no longer exists"); // The `checkAndClearHistograms()` call above cleared the histograms. After // that, nothing else should have been recorded for the response. MerinoTestUtils.checkAndClearHistograms({ histograms, response: null, latencyRecorded: true, client: gClient, }); MerinoTestUtils.server.reset(); UrlbarPrefs.set("merino.timeoutMs", originalPrefTimeoutMs); } // By design, when a fetch times out, the client allows it to finish so we can // record its latency. But when a second fetch starts before the first finishes, // the client should abort the first so that there is at most one fetch at a // time. add_task(async function newFetchAbortsPrevious() { let histograms = MerinoTestUtils.getAndClearHistograms(); // Make the server return a very delayed response so that it would time out // and we can start a second fetch that will abort the first fetch. MerinoTestUtils.server.response.delay = 100 * UrlbarPrefs.get("merino.timeoutMs"); // Do the first fetch. await fetchAndCheckSuggestions({ expected: [] }); // At this point, the timeout timer has fired, causing our `fetch()` call to // return. However, the client's internal fetch should still be ongoing. Assert.equal(gClient.lastFetchStatus, "timeout", "The request timed out"); // The client should have nulled out the timeout timer. Assert.strictEqual( gClient._test_timeoutTimer, null, "timeoutTimer does not exist after first fetch finished" ); // The fetch controller should still exist because the fetch should remain // ongoing. Assert.ok( gClient._test_fetchController, "fetchController still exists after first fetch finished" ); Assert.ok( !gClient._test_fetchController.signal.aborted, "fetchController is not aborted" ); // The latency histogram should not be updated since the fetch is still // ongoing. MerinoTestUtils.checkAndClearHistograms({ histograms, response: "timeout", latencyRecorded: false, latencyStopwatchRunning: true, client: gClient, }); // Do the second fetch. This time don't delay the response. delete MerinoTestUtils.server.response.delay; await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS, }); Assert.equal( gClient.lastFetchStatus, "success", "The request finished successfully" ); // The fetch was successful, so the client should have nulled out both // properties. Assert.ok( !gClient._test_fetchController, "fetchController does not exist after second fetch finished" ); Assert.strictEqual( gClient._test_timeoutTimer, null, "timeoutTimer does not exist after second fetch finished" ); MerinoTestUtils.checkAndClearHistograms({ histograms, response: "success", latencyRecorded: true, client: gClient, }); MerinoTestUtils.server.reset(); }); // The client should not include the `clientVariants` and `providers` search // params when they are not set. add_task(async function clientVariants_providers_notSet() { UrlbarPrefs.set("merino.clientVariants", ""); UrlbarPrefs.set("merino.providers", ""); await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS, }); MerinoTestUtils.server.checkAndClearRequests([ { params: { [SEARCH_PARAMS.QUERY]: "search", [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, }, }, ]); UrlbarPrefs.clear("merino.clientVariants"); UrlbarPrefs.clear("merino.providers"); }); // The client should include the `clientVariants` and `providers` search params // when they are set using preferences. add_task(async function clientVariants_providers_preferences() { UrlbarPrefs.set("merino.clientVariants", "green"); UrlbarPrefs.set("merino.providers", "pink"); await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS, }); MerinoTestUtils.server.checkAndClearRequests([ { params: { [SEARCH_PARAMS.QUERY]: "search", [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, [SEARCH_PARAMS.CLIENT_VARIANTS]: "green", [SEARCH_PARAMS.PROVIDERS]: "pink", }, }, ]); UrlbarPrefs.clear("merino.clientVariants"); UrlbarPrefs.clear("merino.providers"); }); // The client should include the `providers` search param when it's set by // passing in the `providers` argument to `fetch()`. The argument should // override the pref. This tests a single provider. add_task(async function providers_arg_single() { UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); await fetchAndCheckSuggestions({ args: { query: "search", providers: ["argShouldBeUsed"] }, expected: EXPECTED_MERINO_SUGGESTIONS, }); MerinoTestUtils.server.checkAndClearRequests([ { params: { [SEARCH_PARAMS.QUERY]: "search", [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, [SEARCH_PARAMS.PROVIDERS]: "argShouldBeUsed", }, }, ]); UrlbarPrefs.clear("merino.providers"); }); // The client should include the `providers` search param when it's set by // passing in the `providers` argument to `fetch()`. The argument should // override the pref. This tests multiple providers. add_task(async function providers_arg_many() { UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); await fetchAndCheckSuggestions({ args: { query: "search", providers: ["one", "two", "three"] }, expected: EXPECTED_MERINO_SUGGESTIONS, }); MerinoTestUtils.server.checkAndClearRequests([ { params: { [SEARCH_PARAMS.QUERY]: "search", [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, [SEARCH_PARAMS.PROVIDERS]: "one,two,three", }, }, ]); UrlbarPrefs.clear("merino.providers"); }); // The client should include the `providers` search param when it's set by // passing in the `providers` argument to `fetch()` even when it's an empty // array. The argument should override the pref. add_task(async function providers_arg_empty() { UrlbarPrefs.set("merino.providers", "prefShouldNotBeUsed"); await fetchAndCheckSuggestions({ args: { query: "search", providers: [] }, expected: EXPECTED_MERINO_SUGGESTIONS, }); MerinoTestUtils.server.checkAndClearRequests([ { params: { [SEARCH_PARAMS.QUERY]: "search", [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, [SEARCH_PARAMS.PROVIDERS]: "", }, }, ]); UrlbarPrefs.clear("merino.providers"); }); // Passes invalid `providers` arguments to `fetch()`. add_task(async function providers_arg_invalid() { let providersValues = ["", "nonempty", {}]; for (let providers of providersValues) { info("Calling fetch() with providers: " + JSON.stringify(providers)); // `Assert.throws()` doesn't seem to work with async functions... let error; try { await gClient.fetch({ providers, query: "search" }); } catch (e) { error = e; } Assert.ok(error, "fetch() threw an error"); Assert.equal( error.message, "providers must be an array if given", "Expected error was thrown" ); } }); // Tests setting the endpoint URL and query parameters via Nimbus. add_task(async function nimbus() { // Clear the endpoint pref so we know the URL is not being fetched from it. let originalEndpointURL = UrlbarPrefs.get("merino.endpointURL"); UrlbarPrefs.set("merino.endpointURL", ""); await UrlbarTestUtils.initNimbusFeature(); // First, with the endpoint pref set to an empty string, make sure no Merino // suggestion are returned. await fetchAndCheckSuggestions({ expected: [] }); // Now install an experiment that sets the endpoint and other Merino-related // variables. Make sure a suggestion is returned and the request includes the // correct query params. // `param`: The param name in the request URL // `value`: The value to use for the param // `variable`: The name of the Nimbus variable corresponding to the param let expectedParams = [ { param: SEARCH_PARAMS.CLIENT_VARIANTS, value: "test-client-variants", variable: "merinoClientVariants", }, { param: SEARCH_PARAMS.PROVIDERS, value: "test-providers", variable: "merinoProviders", }, ]; // Set up the Nimbus variable values to create the experiment with. let experimentValues = { merinoEndpointURL: MerinoTestUtils.server.url.toString(), }; for (let { variable, value } of expectedParams) { experimentValues[variable] = value; } await withExperiment(experimentValues, async () => { await fetchAndCheckSuggestions({ expected: EXPECTED_MERINO_SUGGESTIONS }); let params = { [SEARCH_PARAMS.QUERY]: "search", [SEARCH_PARAMS.SEQUENCE_NUMBER]: 0, }; for (let { param, value } of expectedParams) { params[param] = value; } MerinoTestUtils.server.checkAndClearRequests([{ params }]); }); UrlbarPrefs.set("merino.endpointURL", originalEndpointURL); }); async function fetchAndCheckSuggestions({ expected, args = { query: "search", }, }) { let actual = await gClient.fetch(args); Assert.deepEqual(actual, expected, "Expected suggestions"); gClient.resetSession(); } async function withExperiment(values, callback) { let { enrollmentPromise, doExperimentCleanup } = ExperimentFakes.enrollmentHelper( ExperimentFakes.recipe("mock-experiment", { active: true, branches: [ { slug: "treatment", features: [ { featureId: NimbusFeatures.urlbar.featureId, value: { enabled: true, ...values, }, }, ], }, ], }) ); await enrollmentPromise; await callback(); await doExperimentCleanup(); }