fune/browser/components/urlbar/private/SuggestBackendRust.sys.mjs
Drew Willcoxon a10a5ff60a Bug 1861540 - Part 2: Update tests to use the local remote settings server. r=daisuke
This modifies tests to use the server. There are a few important points to call
out:

This means tests are now using the real Rust component, and we need to make sure
the test RS data is valid and matches what Rust expects. For example, I had to
add `icon` properties to suggestions and set the `advertiser` to "Wikipedia" for
non-sponsored suggestions. Otherwise Rust hits an error on ingest. I also
removed some test cases because they tested behaviors that are impossible with
Rust, for example Pocket keywords that are duplicated in the high- and
low-confidence arrays.

We need to be careful to wait until Suggest is done syncing from remote settings
regardless of whether it's using the JS or Rust backend. I added a way to force
the backends to sync. That way, tests can force a sync, wait for it to finish,
and be sure that all sync activity is done.

A common pattern in tests is to call `ensureQuickSuggestInit()` and then set
Suggest-related prefs (or vice versa). This is a little problematic because both
`ensureQuickSuggestInit()` and setting prefs can cause Suggest to start a sync.
It's more problematic now that we're not mocking remote settings or Rust. So I
combined the two by adding a `prefs` param to `ensureQuickSuggestInit()`. That
way, tests can be sure that all syncing is done once that function returns.

Depends on D192037, D192124

Differential Revision: https://phabricator.services.mozilla.com/D192038
2023-10-30 23:33:12 +00:00

279 lines
10 KiB
JavaScript

/* 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/. */
import { BaseFeature } from "resource:///modules/urlbar/private/BaseFeature.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
SuggestIngestionConstraints: "resource://gre/modules/RustSuggest.sys.mjs",
SuggestStore: "resource://gre/modules/RustSuggest.sys.mjs",
Suggestion: "resource://gre/modules/RustSuggest.sys.mjs",
SuggestionProvider: "resource://gre/modules/RustSuggest.sys.mjs",
SuggestionQuery: "resource://gre/modules/RustSuggest.sys.mjs",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
});
XPCOMUtils.defineLazyServiceGetter(
lazy,
"timerManager",
"@mozilla.org/updates/timer-manager;1",
"nsIUpdateTimerManager"
);
const SUGGEST_STORE_BASENAME = "suggest.sqlite";
// This ID is used to register our ingest timer with nsIUpdateTimerManager.
const INGEST_TIMER_ID = "suggest-ingest";
const INGEST_TIMER_LAST_UPDATE_PREF = `app.update.lastUpdateTime.${INGEST_TIMER_ID}`;
// Maps from `suggestion.constructor` to the corresponding name of the
// suggestion type. See `getSuggestionType()` for details.
const gSuggestionTypesByCtor = new WeakMap();
/**
* The Suggest Rust backend. Not used when the remote settings JS backend is
* enabled.
*
* This class returns suggestions served by the Rust component. These are the
* primary related architectural pieces (see bug 1851256 for details):
*
* (1) The `suggest` Rust component, which lives in the application-services
* repo [1] and is periodically vendored into mozilla-central [2] and then
* built into the Firefox binary.
* (2) `suggest.udl`, which is part of the Rust component's source files and
* defines the interface exposed to foreign-function callers like JS [3, 4].
* (3) `RustSuggest.sys.mjs` [5], which contains the JS bindings generated from
* `suggest.udl` by UniFFI. The classes defined in `RustSuggest.sys.mjs` are
* what we consume here in this file. If you have a question about the JS
* interface to the Rust component, try checking `RustSuggest.sys.mjs`, but
* as you get accustomed to UniFFI JS conventions you may find it simpler to
* refer directly to `suggest.udl`.
* (4) `config.toml` [6], which defines which functions in the JS bindings are
* sync and which are async. Functions default to the "worker" thread, which
* means they are async. Some functions are "main", which means they are
* sync. Async functions return promises. This information is reflected in
* `RustSuggest.sys.mjs` of course: If a function is "worker", its JS
* binding will return a promise, and if it's "main" it won't.
*
* [1] https://github.com/mozilla/application-services/tree/main/components/suggest
* [2] https://searchfox.org/mozilla-central/source/third_party/rust/suggest
* [3] https://github.com/mozilla/application-services/blob/main/components/suggest/src/suggest.udl
* [4] https://searchfox.org/mozilla-central/source/third_party/rust/suggest/src/suggest.udl
* [5] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs
* [6] https://searchfox.org/mozilla-central/source/toolkit/components/uniffi-bindgen-gecko-js/config.toml
*/
export class SuggestBackendRust extends BaseFeature {
get shouldEnable() {
return lazy.UrlbarPrefs.get("quickSuggestRustEnabled");
}
/**
* @returns {Promise}
* If ingest is pending this will be resolved when it's done. Otherwise it
* was resolved when the previous ingest finished.
*/
get ingestPromise() {
return this.#ingestPromise;
}
enable(enabled) {
if (enabled) {
this.#init();
} else {
this.#uninit();
}
}
async query(searchString) {
this.logger.info("Handling query: " + JSON.stringify(searchString));
if (!this.#store) {
// There must have been an error creating `#store`.
this.logger.info("#store is null, returning");
return [];
}
searchString = searchString.toLocaleLowerCase();
// Build the list of Rust providers to query. Each provider is identified by
// an integer value defined on the `SuggestionProvider` object. Here we
// convert the Rust suggestion types of our registered features to their
// corresponding provider integer values.
let providers = [];
for (let type of lazy.QuickSuggest.registeredRustSuggestionTypes) {
let key = type.toUpperCase();
this.logger.debug("Adding provider to query:" + key);
if (!lazy.SuggestionProvider.hasOwnProperty(key)) {
this.logger.error(`SuggestionProvider["${key}"] is not defined!`);
continue;
}
providers.push(lazy.SuggestionProvider[key]);
}
let suggestions = await this.#store.query(
new lazy.SuggestionQuery(searchString, providers)
);
for (let suggestion of suggestions) {
let type = getSuggestionType(suggestion);
if (!type) {
continue;
}
suggestion.source = "rust";
suggestion.provider = type;
suggestion.is_sponsored = type == "Amp";
if (Array.isArray(suggestion.icon)) {
suggestion.icon_blob = new Blob([new Uint8Array(suggestion.icon)]);
delete suggestion.icon;
}
}
this.logger.debug(
"Got suggestions: " + JSON.stringify(suggestions, null, 2)
);
return suggestions;
}
cancelQuery() {
this.#store?.interrupt();
}
/**
* nsITimerCallback
*/
notify() {
this.logger.info("Ingest timer fired");
this.#ingest();
}
get #storePath() {
return PathUtils.join(
Services.dirsvc.get("ProfLD", Ci.nsIFile).path,
SUGGEST_STORE_BASENAME
);
}
#init() {
// Create the store.
let path = this.#storePath;
this.logger.info("Initializing SuggestStore: " + path);
try {
this.#store = lazy.SuggestStore.init(
path,
SuggestBackendRust._test_remoteSettingsConfig
);
} catch (error) {
this.logger.error("Error initializing SuggestStore:");
this.logger.error(error);
return;
}
// Before registering the ingest timer, check the last-update pref, which is
// created by the timer manager the first time we register it. If the pref
// doesn't exist, this is the first time the Rust backend has been enabled
// in this profile. In that case, perform ingestion immediately to make
// automated and manual testing easier. Otherwise we'd need to wait at least
// 30s (`app.update.timerFirstInterval`) for the timer manager to call us
// back (and we'd also need to pass false for `skipFirst` below).
let lastIngestSecs = Services.prefs.getIntPref(
INGEST_TIMER_LAST_UPDATE_PREF,
0
);
if (lastIngestSecs) {
this.logger.info(
`Last ingest: ${lastIngestSecs}s since epoch. Not ingesting now`
);
} else {
this.logger.info("Last ingest time not found. Ingesting now");
this.#ingest();
}
// Register the ingest timer.
lazy.timerManager.registerTimer(
INGEST_TIMER_ID,
this,
lazy.UrlbarPrefs.get("quicksuggest.rustIngestIntervalSeconds"),
true // skipFirst
);
}
#uninit() {
this.#store = null;
lazy.timerManager.unregisterTimer(INGEST_TIMER_ID);
}
async #ingest() {
this.logger.info("Starting ingest");
this.#ingestPromise = this.#store.ingest(
new lazy.SuggestIngestionConstraints()
);
try {
await this.#ingestPromise;
} catch (error) {
// Ingest can throw a `SuggestApiError` subclass called `Other` that has a
// custom `reason` message, which is very helpful for diagnosing problems
// with remote settings data in tests in particular.
this.logger.error("Ingest error: " + (error.reason ?? error));
}
this.logger.info("Finished ingest");
}
async _test_ingest() {
await this.#ingest();
}
// The `SuggestStore` instance.
#store;
#ingestPromise;
}
/**
* Returns the type of a suggestion.
*
* @param {Suggestion} suggestion
* A suggestion object, an instance of one of the `Suggestion` subclasses.
* @returns {string}
* The suggestion's type, e.g., "Amp", "Wikipedia", etc.
*/
function getSuggestionType(suggestion) {
// Suggestion objects served by the Rust component don't have any inherent
// type information other than the classes they are instances of. There's no
// `type` property, for example. There's a base `Suggestion` class and many
// `Suggestion` subclasses, one per type of suggestion. Each suggestion object
// is an instance of one of these subclasses. We derive a suggestion's type
// from the subclass it's an instance of.
//
// Unfortunately the subclasses are all anonymous, which means
// `suggestion.constructor.name` is always an empty string. (This is due to
// how UniFFI generates JS bindings.) Instead, the subclasses are defined as
// properties on the base `Suggestion` class. For example,
// `Suggestion.Wikipedia` is the (anonymous) Wikipedia suggestion class. To
// find a suggestion's subclass, we loop through the keys on `Suggestion`
// until we find the value the suggestion is an instance of. To avoid doing
// this every time, we cache the mapping from suggestion constructor to key
// the first time we encounter a new suggestion subclass.
let type = gSuggestionTypesByCtor.get(suggestion.constructor);
if (!type) {
type = Object.keys(lazy.Suggestion).find(
key => suggestion instanceof lazy.Suggestion[key]
);
if (type) {
gSuggestionTypesByCtor.set(suggestion.constructor, type);
} else {
this.logger.error(
"Unexpected error: Suggestion class not found on `Suggestion`. " +
"Did the Rust component or its JS bindings change? " +
"The suggestion is: " +
JSON.stringify(suggestion)
);
}
}
return type;
}