Files
tubestation/browser/components/urlbar/tests/quicksuggest/MerinoTestUtils.sys.mjs
Drew Willcoxon 15f1e8095d Bug 1831971 - Remove zero-prefix functionality from the weather suggestion and don't start fetches until a config is set. r=daisuke
This fixes the bug by not starting fetches until a config is set from either
remote settings or Nimbus. By "config" I mean keywords basically, but we sync
more than keywords -- the min keyword length and min keyword length cap -- so
that's why I use different term. So, before remote settings is synced, no config
will be set, so no fetches will happen, so the suggestion will be null. When the
urlbar provider starts a query, it will see the suggestion is null and not add a
result. Once a config is set from RS or Nimbus, we'll start fetching.

Currently we allow zero prefix when keywords or the min keyword length aren't
set. This patch removes that functionality because on second thought, there's
not a safe and obvious way to support zero prefix using these keyword-related
config properties/variables by themselves, and zero prefix isn't a feature
requirement anymore anyway. If we wanted to keep supporting it with these
properties/variables, there are a few options, and I don't like any of them:

* If `keywords` is undefined or null, use zero prefix. This is dangerous because
  we may make a mistake in RS or Nimbus and forget to set it. Also, we use null
  as the default value for the Nimbus var, and since we use UrlbarPrefs to
  access Nimbus vars, there's no good way to tell whether null was set
  intentionally or not.
* If `keywords` is an empty array, use zero prefix. This is awkward because the
  user can now increment the min keyword length, which means the keywords array
  kept by `Weather` can become empty when the min keyword length is incremented
  a lot. In that case, no suggestion should be shown at all.
* If `min_keyword_length` is zero/falsey, use zero prefix. This has the same
  problems as the first point above.

If we do need to support zero prefix again in the future, I think we should add
an RS property/Nimbus variable that makes it clear what's happening, e.g., a
`useZeroPrefix` boolean.

I removed the exposure scalar because it's entirely based on zero prefix. We can
use Glean for that now anyway.

I also noticed weather suggestions are case insenstive, so I fixed that and
added a test task.

Differential Revision: https://phabricator.services.mozilla.com/D177448
2023-05-09 06:37:34 +00:00

770 lines
22 KiB
JavaScript

/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
QuickSuggestRemoteSettings:
"resource:///modules/urlbar/private/QuickSuggestRemoteSettings.sys.mjs",
TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
});
const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
// The following properties and methods are copied from the test scope to the
// test utils object so they can be easily accessed. Be careful about assuming a
// particular property will be defined because depending on the scope -- browser
// test or xpcshell test -- some may not be.
const TEST_SCOPE_PROPERTIES = [
"Assert",
"EventUtils",
"info",
"registerCleanupFunction",
];
const SEARCH_PARAMS = {
CLIENT_VARIANTS: "client_variants",
PROVIDERS: "providers",
QUERY: "q",
SEQUENCE_NUMBER: "seq",
SESSION_ID: "sid",
};
const REQUIRED_SEARCH_PARAMS = [
SEARCH_PARAMS.QUERY,
SEARCH_PARAMS.SEQUENCE_NUMBER,
SEARCH_PARAMS.SESSION_ID,
];
// We set the client timeout to a large value to avoid intermittent failures in
// CI, especially TV tests, where the Merino fetch unexpectedly doesn't finish
// before the default timeout.
const CLIENT_TIMEOUT_MS = 2000;
const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS";
const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE";
// Maps from string labels of the `FX_URLBAR_MERINO_RESPONSE` histogram to their
// numeric values.
const RESPONSE_HISTOGRAM_VALUES = {
success: 0,
timeout: 1,
network_error: 2,
http_error: 3,
no_suggestion: 4,
};
const WEATHER_KEYWORD = "weather";
const WEATHER_RS_DATA = {
keywords: [WEATHER_KEYWORD],
};
const WEATHER_SUGGESTION = {
title: "Weather for San Francisco",
url: "https://example.com/weather",
provider: "accuweather",
is_sponsored: false,
score: 0.2,
icon: null,
city_name: "San Francisco",
current_conditions: {
url: "https://example.com/weather-current-conditions",
summary: "Mostly cloudy",
icon_id: 6,
temperature: { c: 15.5, f: 60.0 },
},
forecast: {
url: "https://example.com/weather-forecast",
summary: "Pleasant Saturday",
high: { c: 21.1, f: 70.0 },
low: { c: 13.9, f: 57.0 },
},
};
// We set the weather suggestion fetch interval to an absurdly large value so it
// absolutely will not fire during tests.
const WEATHER_FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Test utils for Merino.
*/
class _MerinoTestUtils {
/**
* Initializes the utils.
*
* @param {object} scope
* The global JS scope where tests are being run. This allows the instance
* to access test helpers like `Assert` that are available in the scope.
*/
init(scope) {
if (!scope) {
throw new Error("MerinoTestUtils.init() must be called with a scope");
}
this.#initDepth++;
scope.info?.("MerinoTestUtils init: Depth is now " + this.#initDepth);
for (let p of TEST_SCOPE_PROPERTIES) {
this[p] = scope[p];
}
// If you add other properties to `this`, null them in `uninit()`.
if (!this.#server) {
this.#server = new MockMerinoServer(scope);
}
lazy.UrlbarPrefs.set("merino.timeoutMs", CLIENT_TIMEOUT_MS);
scope.registerCleanupFunction?.(() => {
scope.info?.("MerinoTestUtils cleanup function");
this.uninit();
});
}
/**
* Uninitializes the utils. If they were created with a test scope that
* defines `registerCleanupFunction()`, you don't need to call this yourself
* because it will automatically be called as a cleanup function. Otherwise
* you'll need to call this.
*/
uninit() {
this.#initDepth--;
this.info?.("MerinoTestUtils uninit: Depth is now " + this.#initDepth);
if (this.#initDepth) {
this.info?.("MerinoTestUtils uninit: Bailing because depth > 0");
return;
}
this.info?.("MerinoTestUtils uninit: Now uninitializing");
for (let p of TEST_SCOPE_PROPERTIES) {
this[p] = null;
}
this.#server.uninit();
this.#server = null;
lazy.UrlbarPrefs.clear("merino.timeoutMs");
}
/**
* @returns {object}
* The names of URL search params.
*/
get SEARCH_PARAMS() {
return SEARCH_PARAMS;
}
/**
* @returns {string}
* The weather keyword in `WEATHER_RS_DATA`. Can be used as a search string
* to match the weather suggestion.
*/
get WEATHER_KEYWORD() {
return WEATHER_KEYWORD;
}
/**
* @returns {object}
* Default remote settings data that sets up `WEATHER_KEYWORD` as the
* keyword for the weather suggestion.
*/
get WEATHER_RS_DATA() {
return { ...WEATHER_RS_DATA };
}
/**
* @returns {object}
* A mock weather suggestion.
*/
get WEATHER_SUGGESTION() {
return WEATHER_SUGGESTION;
}
/**
* @returns {MockMerinoServer}
* The mock Merino server. The server isn't started until its `start()`
* method is called.
*/
get server() {
return this.#server;
}
/**
* Clears the Merino-related histograms and returns them.
*
* @param {object} options
* Options
* @param {string} options.extraLatency
* The name of another latency histogram you expect to be updated.
* @param {string} options.extraResponse
* The name of another response histogram you expect to be updated.
* @returns {object}
* An object of histograms: `{ latency, response }`
* `latency` and `response` are both arrays of Histogram objects.
*/
getAndClearHistograms({
extraLatency = undefined,
extraResponse = undefined,
} = {}) {
let histograms = {
latency: [
lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_LATENCY),
],
response: [
lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_RESPONSE),
],
};
if (extraLatency) {
histograms.latency.push(
lazy.TelemetryTestUtils.getAndClearHistogram(extraLatency)
);
}
if (extraResponse) {
histograms.response.push(
lazy.TelemetryTestUtils.getAndClearHistogram(extraResponse)
);
}
return histograms;
}
/**
* Asserts the Merino-related histograms are updated as expected. Clears the
* histograms before returning.
*
* @param {object} options
* Options object
* @param {MerinoClient} options.client
* The relevant `MerinoClient` instance. This is used to check the latency
* stopwatch.
* @param {object} options.histograms
* The histograms object returned from `getAndClearHistograms()`.
* @param {string} options.response
* The expected string label for the `response` histogram. If the histogram
* should not be recorded, pass null.
* @param {boolean} options.latencyRecorded
* Whether the latency histogram is expected to contain a value.
* @param {boolean} options.latencyStopwatchRunning
* Whether the latency stopwatch is expected to be running.
*/
checkAndClearHistograms({
client,
histograms,
response,
latencyRecorded,
latencyStopwatchRunning = false,
}) {
// Check the response histograms.
if (response) {
this.Assert.ok(
RESPONSE_HISTOGRAM_VALUES.hasOwnProperty(response),
"Sanity check: Expected response is valid: " + response
);
for (let histogram of histograms.response) {
lazy.TelemetryTestUtils.assertHistogram(
histogram,
RESPONSE_HISTOGRAM_VALUES[response],
1
);
}
} else {
for (let histogram of histograms.response) {
this.Assert.strictEqual(
histogram.snapshot().sum,
0,
"Response histogram not updated: " + histogram.name()
);
}
}
// Check the latency histograms.
if (latencyRecorded) {
// There should be a single value across all buckets.
for (let histogram of histograms.latency) {
this.Assert.deepEqual(
Object.values(histogram.snapshot().values).filter(v => v > 0),
[1],
"Latency histogram updated: " + histogram.name()
);
}
} else {
for (let histogram of histograms.latency) {
this.Assert.strictEqual(
histogram.snapshot().sum,
0,
"Latency histogram not updated: " + histogram.name()
);
}
}
// Check the latency stopwatch.
this.Assert.equal(
TelemetryStopwatch.running(
HISTOGRAM_LATENCY,
client._test_latencyStopwatchInstance
),
latencyStopwatchRunning,
"Latency stopwatch running as expected"
);
// Clear histograms.
for (let histogramArray of Object.values(histograms)) {
for (let histogram of histogramArray) {
histogram.clear();
}
}
}
/**
* Initializes the quick suggest weather feature and mock Merino server.
*/
async initWeather() {
await this.server.start();
this.server.response.body.suggestions = [WEATHER_SUGGESTION];
lazy.QuickSuggestRemoteSettings._test_ignoreSettingsSync = true;
lazy.QuickSuggest.weather._test_fetchIntervalMs = WEATHER_FETCH_INTERVAL_MS;
// Enabling weather will trigger a fetch. Wait for it to finish so the
// suggestion is ready when this function returns.
let fetchPromise = lazy.QuickSuggest.weather.waitForFetches();
lazy.QuickSuggest.weather._test_setRsData({ ...WEATHER_RS_DATA });
lazy.UrlbarPrefs.set("weather.featureGate", true);
lazy.UrlbarPrefs.set("suggest.weather", true);
await fetchPromise;
this.Assert.equal(
lazy.QuickSuggest.weather._test_pendingFetchCount,
0,
"No pending fetches after awaiting initial fetch"
);
this.registerCleanupFunction?.(async () => {
lazy.UrlbarPrefs.clear("weather.featureGate");
lazy.UrlbarPrefs.clear("suggest.weather");
lazy.QuickSuggest.weather._test_fetchIntervalMs = -1;
});
}
#initDepth = 0;
#server = null;
}
/**
* A mock Merino server with useful helper methods.
*/
class MockMerinoServer {
/**
* Until `start()` is called the server isn't started and `this.url` is null.
*
* @param {object} scope
* The global JS scope where tests are being run. This allows the instance
* to access test helpers like `Assert` that are available in the scope.
*/
constructor(scope) {
scope.info?.("MockMerinoServer constructor");
for (let p of TEST_SCOPE_PROPERTIES) {
this[p] = scope[p];
}
let path = "/merino";
this.#httpServer = new HttpServer();
this.#httpServer.registerPathHandler(path, (req, resp) =>
this.#handleRequest(req, resp)
);
this.#baseURL = new URL("http://localhost/");
this.#baseURL.pathname = path;
this.reset();
}
/**
* Uninitializes the server.
*/
uninit() {
this.info?.("MockMerinoServer uninit");
for (let p of TEST_SCOPE_PROPERTIES) {
this[p] = null;
}
}
/**
* @returns {nsIHttpServer}
* The underlying HTTP server.
*/
get httpServer() {
return this.#httpServer;
}
/**
* @returns {URL}
* The server's endpoint URL or null if the server isn't running.
*/
get url() {
return this.#url;
}
/**
* @returns {Array}
* Array of received nsIHttpRequest objects. Requests are continually
* collected, and the list can be cleared with `reset()`.
*/
get requests() {
return this.#requests;
}
/**
* @returns {object}
* An object that describes the response that the server will return. Can be
* modified or set to a different object to change the response. Can be
* reset to the default reponse by calling `reset()`. For details see
* `makeDefaultResponse()` and `#handleRequest()`. In summary:
*
* {
* status,
* contentType,
* delay,
* body: {
* request_id,
* suggestions,
* },
* }
*/
get response() {
return this.#response;
}
set response(value) {
this.#response = value;
}
/**
* Starts the server and sets `this.url`. If the server was created with a
* test scope that defines `registerCleanupFunction()`, you don't need to call
* `stop()` yourself because it will automatically be called as a cleanup
* function. Otherwise you'll need to call `stop()`.
*/
async start() {
if (this.#url) {
return;
}
this.info("MockMerinoServer starting");
this.#httpServer.start(-1);
this.#url = new URL(this.#baseURL);
this.#url.port = this.#httpServer.identity.primaryPort;
this._originalEndpointURL = lazy.UrlbarPrefs.get("merino.endpointURL");
lazy.UrlbarPrefs.set("merino.endpointURL", this.#url.toString());
this.registerCleanupFunction?.(() => this.stop());
// Wait for the server to actually start serving. In TV tests, where the
// server is created over and over again, sometimes it doesn't seem to be
// ready after being recreated even after `#httpServer.start()` is called.
this.info("MockMerinoServer waiting to start serving...");
this.reset();
let suggestion;
while (!suggestion) {
let response = await fetch(this.#url);
let body = await response?.json();
suggestion = body?.suggestions?.[0];
}
this.reset();
this.info("MockMerinoServer is now serving");
}
/**
* Stops the server and cleans up other state.
*/
async stop() {
if (!this.#url) {
return;
}
// `uninit()` may have already been called by this point and removed
// `this.info()`, so don't assume it's defined.
this.info?.("MockMerinoServer stopping");
// Cancel delayed-response timers and resolve their promises. Otherwise, if
// a test awaits this method before finishing, it will hang until the timers
// fire and allow the server to send the responses.
this.#cancelDelayedResponses();
await this.#httpServer.stop();
this.#url = null;
lazy.UrlbarPrefs.set("merino.endpointURL", this._originalEndpointURL);
this.info?.("MockMerinoServer is now stopped");
}
/**
* Returns a new object that describes the default response the server will
* return.
*
* @returns {object}
*/
makeDefaultResponse() {
return {
status: 200,
contentType: "application/json",
body: {
request_id: "request_id",
suggestions: [
{
provider: "adm",
full_keyword: "full_keyword",
title: "title",
url: "url",
icon: null,
impression_url: "impression_url",
click_url: "click_url",
block_id: 1,
advertiser: "advertiser",
is_sponsored: true,
score: 1,
},
],
},
};
}
/**
* Clears the received requests and sets the response to the default.
*/
reset() {
this.#requests = [];
this.response = this.makeDefaultResponse();
this.#cancelDelayedResponses();
}
/**
* Asserts a given list of requests has been received. Clears the list of
* received requests before returning.
*
* @param {Array} expected
* The expected requests. Each item should be an object: `{ params }`
*/
checkAndClearRequests(expected) {
let actual = this.requests.map(req => {
let params = new URLSearchParams(req.queryString);
return { params: Object.fromEntries(params) };
});
this.info("Checking requests");
this.info("actual: " + JSON.stringify(actual));
this.info("expect: " + JSON.stringify(expected));
// Check the request count.
this.Assert.equal(actual.length, expected.length, "Expected request count");
if (actual.length != expected.length) {
return;
}
// Check each request.
for (let i = 0; i < actual.length; i++) {
let a = actual[i];
let e = expected[i];
this.info("Checking requests at index " + i);
this.info("actual: " + JSON.stringify(a));
this.info("expect: " + JSON.stringify(e));
// Check required search params.
for (let p of REQUIRED_SEARCH_PARAMS) {
this.Assert.ok(
a.params.hasOwnProperty(p),
"Required param is present in actual request: " + p
);
if (p != SEARCH_PARAMS.SESSION_ID) {
this.Assert.ok(
e.params.hasOwnProperty(p),
"Required param is present in expected request: " + p
);
}
}
// If the expected request doesn't include a session ID, then:
if (!e.params.hasOwnProperty(SEARCH_PARAMS.SESSION_ID)) {
if (e.params[SEARCH_PARAMS.SEQUENCE_NUMBER] == 0 || i == 0) {
// If its sequence number is zero, then copy the actual request's
// sequence number to the expected request. As a convenience, do the
// same if this is the first request.
e.params[SEARCH_PARAMS.SESSION_ID] =
a.params[SEARCH_PARAMS.SESSION_ID];
} else {
// Otherwise this is not the first request in the session and
// therefore the session ID should be the same as the ID in the
// previous expected request.
e.params[SEARCH_PARAMS.SESSION_ID] =
expected[i - 1].params[SEARCH_PARAMS.SESSION_ID];
}
}
this.Assert.deepEqual(a, e, "Expected request at index " + i);
let actualSessionID = a.params[SEARCH_PARAMS.SESSION_ID];
this.Assert.ok(actualSessionID, "Session ID exists");
this.Assert.ok(
/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(actualSessionID),
"Session ID is a UUID"
);
}
this.#requests = [];
}
/**
* Temporarily creates the conditions for a network error. Any Merino fetches
* that occur during the callback will fail with a network error.
*
* @param {Function} callback
* Callback function.
*/
async withNetworkError(callback) {
// Set the endpoint to a valid, unreachable URL.
let originalURL = lazy.UrlbarPrefs.get("merino.endpointURL");
lazy.UrlbarPrefs.set(
"merino.endpointURL",
"http://localhost/valid-but-unreachable-url"
);
// Set the timeout high enough that the network error exception will happen
// first. On Mac and Linux the fetch naturally times out fairly quickly but
// on Windows it seems to take 5s, so set our artificial timeout to 10s.
let originalTimeout = lazy.UrlbarPrefs.get("merino.timeoutMs");
lazy.UrlbarPrefs.set("merino.timeoutMs", 10000);
await callback();
lazy.UrlbarPrefs.set("merino.endpointURL", originalURL);
lazy.UrlbarPrefs.set("merino.timeoutMs", originalTimeout);
}
/**
* Returns a promise that will resolve when the next request is received.
*
* @returns {Promise}
*/
waitForNextRequest() {
if (!this.#nextRequestDeferred) {
this.#nextRequestDeferred = lazy.PromiseUtils.defer();
}
return this.#nextRequestDeferred.promise;
}
/**
* nsIHttpServer request handler.
*
* @param {nsIHttpRequest} httpRequest
* Request.
* @param {nsIHttpResponse} httpResponse
* Response.
*/
#handleRequest(httpRequest, httpResponse) {
this.info(
"MockMerinoServer received request with query string: " +
JSON.stringify(httpRequest.queryString)
);
this.info(
"MockMerinoServer replying with response: " +
JSON.stringify(this.response)
);
// Add the request to the list of received requests.
this.#requests.push(httpRequest);
// Resolve promises waiting on the next request.
this.#nextRequestDeferred?.resolve();
this.#nextRequestDeferred = null;
// Now set up and finish the response.
httpResponse.processAsync();
let { response } = this;
let finishResponse = () => {
let status = response.status || 200;
httpResponse.setStatusLine("", status, status);
let contentType = response.contentType || "application/json";
httpResponse.setHeader("Content-Type", contentType, false);
if (typeof response.body == "string") {
httpResponse.write(response.body);
} else if (response.body) {
httpResponse.write(JSON.stringify(response.body));
}
httpResponse.finish();
};
if (typeof response.delay != "number") {
finishResponse();
return;
}
// Set up a timer to wait until the delay elapses. Since we called
// `httpResponse.processAsync()`, we need to be careful to always finish the
// response, even if the timer is canceled. Otherwise the server will hang
// when we try to stop it at the end of the test. When an `nsITimer` is
// canceled, its callback is *not* called. Therefore we set up a race
// between the timer's callback and a deferred promise. If the timer is
// canceled, resolving the deferred promise will resolve the race, and the
// response can then be finished.
let delayedResponseID = this.#nextDelayedResponseID++;
this.info(
"MockMerinoServer delaying response: " +
JSON.stringify({ delayedResponseID, delay: response.delay })
);
let deferred = lazy.PromiseUtils.defer();
let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
let record = { timer, resolve: deferred.resolve };
this.#delayedResponseRecords.add(record);
// Don't await this promise.
Promise.race([
deferred.promise,
new Promise(resolve => {
timer.initWithCallback(
resolve,
response.delay,
Ci.nsITimer.TYPE_ONE_SHOT
);
}),
]).then(() => {
this.info(
"MockMerinoServer done delaying response: " +
JSON.stringify({ delayedResponseID })
);
deferred.resolve();
this.#delayedResponseRecords.delete(record);
finishResponse();
});
}
/**
* Cancels the timers for delayed responses and resolves their promises.
*/
#cancelDelayedResponses() {
for (let { timer, resolve } of this.#delayedResponseRecords) {
timer.cancel();
resolve();
}
this.#delayedResponseRecords.clear();
}
#httpServer = null;
#url = null;
#baseURL = null;
#response = null;
#requests = [];
#nextRequestDeferred = null;
#nextDelayedResponseID = 0;
#delayedResponseRecords = new Set();
}
export var MerinoTestUtils = new _MerinoTestUtils();