diff options
Diffstat (limited to 'browser/components/urlbar/private/Weather.sys.mjs')
-rw-r--r-- | browser/components/urlbar/private/Weather.sys.mjs | 896 |
1 files changed, 896 insertions, 0 deletions
diff --git a/browser/components/urlbar/private/Weather.sys.mjs b/browser/components/urlbar/private/Weather.sys.mjs new file mode 100644 index 0000000000..c4dfa8c618 --- /dev/null +++ b/browser/components/urlbar/private/Weather.sys.mjs @@ -0,0 +1,896 @@ +/* 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"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + MerinoClient: "resource:///modules/MerinoClient.sys.mjs", + QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", + UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs", + UrlbarResult: "resource:///modules/UrlbarResult.sys.mjs", + UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs", + UrlbarView: "resource:///modules/UrlbarView.sys.mjs", +}); + +const FETCH_DELAY_AFTER_COMING_ONLINE_MS = 3000; // 3s +const FETCH_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes +const MERINO_PROVIDER = "accuweather"; +const MERINO_TIMEOUT_MS = 5000; // 5s + +const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS"; +const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER"; + +const NOTIFICATIONS = { + CAPTIVE_PORTAL_LOGIN: "captive-portal-login-success", + LINK_STATUS_CHANGED: "network:link-status-changed", + OFFLINE_STATUS_CHANGED: "network:offline-status-changed", + WAKE: "wake_notification", +}; + +const RESULT_MENU_COMMAND = { + HELP: "help", + INACCURATE_LOCATION: "inaccurate_location", + NOT_INTERESTED: "not_interested", + NOT_RELEVANT: "not_relevant", + SHOW_LESS_FREQUENTLY: "show_less_frequently", +}; + +const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather"; + +const WEATHER_DYNAMIC_TYPE = "weather"; +const WEATHER_VIEW_TEMPLATE = { + attributes: { + selectable: true, + }, + children: [ + { + name: "currentConditions", + tag: "span", + children: [ + { + name: "currently", + tag: "div", + }, + { + name: "currentTemperature", + tag: "div", + children: [ + { + name: "temperature", + tag: "span", + }, + { + name: "weatherIcon", + tag: "img", + }, + ], + }, + ], + }, + { + name: "summary", + tag: "span", + overflowable: true, + children: [ + { + name: "top", + tag: "div", + children: [ + { + name: "topNoWrap", + tag: "span", + children: [ + { name: "title", tag: "span", classList: ["urlbarView-title"] }, + { + name: "titleSeparator", + tag: "span", + classList: ["urlbarView-title-separator"], + }, + ], + }, + { + name: "url", + tag: "span", + classList: ["urlbarView-url"], + }, + ], + }, + { + name: "middle", + tag: "div", + children: [ + { + name: "middleNoWrap", + tag: "span", + overflowable: true, + children: [ + { + name: "summaryText", + tag: "span", + }, + { + name: "summaryTextSeparator", + tag: "span", + }, + { + name: "highLow", + tag: "span", + }, + ], + }, + { + name: "highLowWrap", + tag: "span", + }, + ], + }, + { + name: "bottom", + tag: "div", + }, + ], + }, + ], +}; + +/** + * A feature that periodically fetches weather suggestions from Merino. + */ +export class Weather extends BaseFeature { + constructor(...args) { + super(...args); + lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE); + lazy.UrlbarView.addDynamicViewTemplate( + WEATHER_DYNAMIC_TYPE, + WEATHER_VIEW_TEMPLATE + ); + } + + get shouldEnable() { + // The feature itself is enabled by setting these prefs regardless of + // whether any config is defined. This is necessary to allow the feature to + // sync the config from remote settings and Nimbus. Suggestion fetches will + // not start until the config has been either synced from remote settings or + // set by Nimbus. + return ( + lazy.UrlbarPrefs.get("weatherFeatureGate") && + lazy.UrlbarPrefs.get("suggest.weather") + ); + } + + get enablingPreferences() { + return ["suggest.weather"]; + } + + get rustSuggestionTypes() { + return ["Weather"]; + } + + isRustSuggestionTypeEnabled(type) { + // When weather keywords are defined in Nimbus, weather suggestions are + // served by UrlbarProviderWeather. Return false here so the quick suggest + // provider doesn't try to serve them too. + return !lazy.UrlbarPrefs.get("weatherKeywords"); + } + + getSuggestionTelemetryType(suggestion) { + return "weather"; + } + + /** + * @returns {object} + * The last weather suggestion fetched from Merino or null if none. + */ + get suggestion() { + return this.#suggestion; + } + + /** + * @returns {Set} + * The set of keywords that should trigger the weather suggestion. This will + * be null when the Rust backend is enabled and keywords are not defined by + * Nimbus because in that case Rust manages the keywords. Otherwise, it will + * also be null when no config is defined. + */ + get keywords() { + return this.#keywords; + } + + /** + * @returns {number} + * The minimum prefix length of a weather keyword the user must type to + * trigger the suggestion. Note that the strings returned from `keywords` + * already take this into account. The min length is determined from the + * first config source below whose value is non-zero. If no source has a + * non-zero value, zero will be returned, and `this.keywords` will contain + * only full keywords. + * + * 1. The `weather.minKeywordLength` pref, which is set when the user + * increments the min length + * 2. `weatherKeywordsMinimumLength` in Nimbus + * 3. `min_keyword_length` in the weather record in remote settings (i.e., + * the weather config) + */ + get minKeywordLength() { + let minLength = + lazy.UrlbarPrefs.get("weather.minKeywordLength") || + lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength") || + this.#config.minKeywordLength || + 0; + return Math.max(minLength, 0); + } + + /** + * @returns {boolean} + * Weather the min keyword length can be incremented. A cap on the min + * length can be set in remote settings and Nimbus. + */ + get canIncrementMinKeywordLength() { + let nimbusMax = + lazy.UrlbarPrefs.get("weatherKeywordsMinimumLengthCap") || 0; + + let maxKeywordLength; + if (nimbusMax) { + // In Nimbus, the cap is the max keyword length. + maxKeywordLength = nimbusMax; + } else { + // In the RS config, the cap is the max number of times the user can click + // "Show less frequently". The max keyword length is therefore the initial + // min length plus the cap. + let min = this.#config.minKeywordLength; + let cap = lazy.QuickSuggest.backend.config?.showLessFrequentlyCap; + if (min && cap) { + maxKeywordLength = min + cap; + } + } + + return !maxKeywordLength || this.minKeywordLength < maxKeywordLength; + } + + update() { + let wasEnabled = this.isEnabled; + super.update(); + + // This method is called by `QuickSuggest` in a + // `NimbusFeatures.urlbar.onUpdate()` callback, when a change occurs to a + // Nimbus variable or to a pref that's a fallback for a Nimbus variable. A + // config-related variable or pref may have changed, so update keywords, but + // only if the feature was already enabled because if it wasn't, + // `enable(true)` was just called, which calls `#init()`, which calls + // `#updateKeywords()`. + if (wasEnabled && this.isEnabled) { + this.#updateKeywords(); + } + } + + enable(enabled) { + if (enabled) { + this.#init(); + } else { + this.#uninit(); + } + } + + /** + * Increments the minimum prefix length of a weather keyword the user must + * type to trigger the suggestion, if possible. A cap on the min length can be + * set in remote settings and Nimbus, and if the cap has been reached, the + * length is not incremented. + */ + incrementMinKeywordLength() { + if (this.canIncrementMinKeywordLength) { + lazy.UrlbarPrefs.set( + "weather.minKeywordLength", + this.minKeywordLength + 1 + ); + } + } + + /** + * Returns a promise that resolves when all pending fetches finish, if there + * are pending fetches. If there aren't, the promise resolves when all pending + * fetches starting with the next fetch finish. + * + * @returns {Promise} + */ + waitForFetches() { + if (!this.#waitForFetchesDeferred) { + this.#waitForFetchesDeferred = Promise.withResolvers(); + } + return this.#waitForFetchesDeferred.promise; + } + + async onRemoteSettingsSync(rs) { + this.logger.debug("Loading weather config from remote settings"); + let records = await rs.get({ filters: { type: "weather" } }); + if (!this.isEnabled) { + return; + } + + this.logger.debug("Got weather records: " + JSON.stringify(records)); + this.#rsConfig = lazy.UrlbarUtils.copySnakeKeysToCamel( + records?.[0]?.weather || {} + ); + this.#updateKeywords(); + } + + makeResult(queryContext, suggestion, searchString) { + // The Rust component doesn't enforce a minimum keyword length, so discard + // the suggestion if the search string isn't long enough. This conditional + // will always be false for the JS backend since in that case keywords are + // never shorter than `minKeywordLength`. + if (searchString.length < this.minKeywordLength) { + return null; + } + + // The Rust component will return a dummy suggestion if the query matches a + // weather keyword. Here in this method we replace it with the actual cached + // weather suggestion from Merino. If there is no cached suggestion, discard + // the Rust suggestion. + if (!this.suggestion) { + return null; + } + + if (suggestion.source == "rust") { + if (lazy.UrlbarPrefs.get("weatherKeywords")) { + // This shouldn't happen since this feature won't enable Rust weather + // suggestions in this case, but just to be safe, discard the suggestion + // if keywords are defined in Nimbus. + return null; + } + // Replace the dummy Rust suggestion with the actual weather suggestion + // from Merino. + suggestion = this.suggestion; + } + + let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c"; + return Object.assign( + new lazy.UrlbarResult( + lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC, + lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, + { + url: suggestion.url, + iconId: suggestion.current_conditions.icon_id, + helpUrl: lazy.QuickSuggest.HELP_URL, + requestId: suggestion.request_id, + dynamicType: WEATHER_DYNAMIC_TYPE, + city: suggestion.city_name, + temperatureUnit: unit, + temperature: suggestion.current_conditions.temperature[unit], + currentConditions: suggestion.current_conditions.summary, + forecast: suggestion.forecast.summary, + high: suggestion.forecast.high[unit], + low: suggestion.forecast.low[unit], + shouldNavigate: true, + } + ), + { + showFeedbackMenu: true, + suggestedIndex: searchString ? 1 : 0, + } + ); + } + + getViewUpdate(result) { + let uppercaseUnit = result.payload.temperatureUnit.toUpperCase(); + return { + currently: { + l10n: { + id: "firefox-suggest-weather-currently", + cacheable: true, + }, + }, + temperature: { + l10n: { + id: "firefox-suggest-weather-temperature", + args: { + value: result.payload.temperature, + unit: uppercaseUnit, + }, + cacheable: true, + excludeArgsFromCacheKey: true, + }, + }, + weatherIcon: { + attributes: { iconId: result.payload.iconId }, + }, + title: { + l10n: { + id: "firefox-suggest-weather-title", + args: { city: result.payload.city }, + cacheable: true, + excludeArgsFromCacheKey: true, + }, + }, + url: { + textContent: result.payload.url, + }, + summaryText: { + l10n: { + id: "firefox-suggest-weather-summary-text", + args: { + currentConditions: result.payload.currentConditions, + forecast: result.payload.forecast, + }, + cacheable: true, + excludeArgsFromCacheKey: true, + }, + }, + highLow: { + l10n: { + id: "firefox-suggest-weather-high-low", + args: { + high: result.payload.high, + low: result.payload.low, + unit: uppercaseUnit, + }, + cacheable: true, + excludeArgsFromCacheKey: true, + }, + }, + highLowWrap: { + l10n: { + id: "firefox-suggest-weather-high-low", + args: { + high: result.payload.high, + low: result.payload.low, + unit: uppercaseUnit, + }, + }, + }, + bottom: { + l10n: { + id: "firefox-suggest-weather-sponsored", + args: { provider: WEATHER_PROVIDER_DISPLAY_NAME }, + cacheable: true, + }, + }, + }; + } + + getResultCommands(result) { + let commands = [ + { + name: RESULT_MENU_COMMAND.INACCURATE_LOCATION, + l10n: { + id: "firefox-suggest-weather-command-inaccurate-location", + }, + }, + ]; + + if (this.canIncrementMinKeywordLength) { + commands.push({ + name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY, + l10n: { + id: "firefox-suggest-command-show-less-frequently", + }, + }); + } + + commands.push( + { + l10n: { + id: "firefox-suggest-command-dont-show-this", + }, + children: [ + { + name: RESULT_MENU_COMMAND.NOT_RELEVANT, + l10n: { + id: "firefox-suggest-command-not-relevant", + }, + }, + { + name: RESULT_MENU_COMMAND.NOT_INTERESTED, + l10n: { + id: "firefox-suggest-command-not-interested", + }, + }, + ], + }, + { name: "separator" }, + { + name: RESULT_MENU_COMMAND.HELP, + l10n: { + id: "urlbar-result-menu-learn-more-about-firefox-suggest", + }, + } + ); + + return commands; + } + + handleCommand(view, result, selType) { + switch (selType) { + case RESULT_MENU_COMMAND.HELP: + // "help" is handled by UrlbarInput, no need to do anything here. + break; + // selType == "dismiss" when the user presses the dismiss key shortcut. + case "dismiss": + case RESULT_MENU_COMMAND.NOT_INTERESTED: + case RESULT_MENU_COMMAND.NOT_RELEVANT: + this.logger.info("Dismissing weather result"); + lazy.UrlbarPrefs.set("suggest.weather", false); + result.acknowledgeDismissalL10n = { + id: "firefox-suggest-dismissal-acknowledgment-all", + }; + view.controller.removeResult(result); + break; + case RESULT_MENU_COMMAND.INACCURATE_LOCATION: + // Currently the only way we record this feedback is in the Glean + // engagement event. As with all commands, it will be recorded with an + // `engagement_type` value that is the command's name, in this case + // `inaccurate_location`. + view.acknowledgeFeedback(result); + break; + case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY: + view.acknowledgeFeedback(result); + this.incrementMinKeywordLength(); + if (!this.canIncrementMinKeywordLength) { + view.invalidateResultMenuCommands(); + } + break; + } + } + + get #config() { + let { rustBackend } = lazy.QuickSuggest; + let config = rustBackend.isEnabled + ? rustBackend.getConfigForSuggestionType(this.rustSuggestionTypes[0]) + : this.#rsConfig; + return config || {}; + } + + get #vpnDetected() { + if (lazy.UrlbarPrefs.get("weather.ignoreVPN")) { + return false; + } + + let linkService = + this._test_linkService || + Cc["@mozilla.org/network/network-link-service;1"].getService( + Ci.nsINetworkLinkService + ); + + // `platformDNSIndications` throws `NS_ERROR_NOT_IMPLEMENTED` on all + // platforms except Windows, so we can't detect a VPN on any other platform. + try { + return ( + linkService.platformDNSIndications & + Ci.nsINetworkLinkService.VPN_DETECTED + ); + } catch (e) {} + return false; + } + + #init() { + // On feature init, we only update keywords and listen for changes that + // affect keywords. Suggestion fetches will not start until either keywords + // exist or Rust is enabled. + this.#updateKeywords(); + lazy.UrlbarPrefs.addObserver(this); + lazy.QuickSuggest.jsBackend.register(this); + } + + #uninit() { + this.#stopFetching(); + lazy.QuickSuggest.jsBackend.unregister(this); + lazy.UrlbarPrefs.removeObserver(this); + this.#keywords = null; + } + + #startFetching() { + if (this.#merino) { + this.logger.debug("Suggestion fetching already started"); + return; + } + + this.logger.debug("Starting suggestion fetching"); + + this.#merino = new lazy.MerinoClient(this.constructor.name); + this.#fetch(); + for (let notif of Object.values(NOTIFICATIONS)) { + Services.obs.addObserver(this, notif); + } + } + + #stopFetching() { + if (!this.#merino) { + this.logger.debug("Suggestion fetching already stopped"); + return; + } + + this.logger.debug("Stopping suggestion fetching"); + + for (let notif of Object.values(NOTIFICATIONS)) { + Services.obs.removeObserver(this, notif); + } + lazy.clearTimeout(this.#fetchTimer); + this.#merino = null; + this.#suggestion = null; + this.#fetchTimer = 0; + } + + async #fetch() { + this.logger.info("Fetching suggestion"); + + if (this.#vpnDetected) { + // A VPN is detected, so Merino will not be able to accurately determine + // the user's location. Set the suggestion to null. We treat this as if + // the network is offline (see below). When the VPN is disconnected, a + // `network:link-status-changed` notification will be sent, triggering a + // new fetch. + this.logger.info("VPN detected, not fetching"); + this.#suggestion = null; + if (!this.#pendingFetchCount) { + this.#waitForFetchesDeferred?.resolve(); + this.#waitForFetchesDeferred = null; + } + return; + } + + // This `Weather` instance may be uninitialized while awaiting the fetch or + // even uninitialized and re-initialized a number of times. Multiple fetches + // may also happen at once. Ignore the fetch below if `#merino` changes or + // another fetch happens in the meantime. + let merino = this.#merino; + let instance = (this.#fetchInstance = {}); + + this.#restartFetchTimer(); + this.#lastFetchTimeMs = Date.now(); + this.#pendingFetchCount++; + + let suggestions; + try { + suggestions = await merino.fetch({ + query: "", + providers: [MERINO_PROVIDER], + timeoutMs: this.#timeoutMs, + extraLatencyHistogram: HISTOGRAM_LATENCY, + extraResponseHistogram: HISTOGRAM_RESPONSE, + }); + } finally { + this.#pendingFetchCount--; + } + + // Reset the Merino client's session so different fetches use different + // sessions. A single session is intended to represent a single user + // engagement in the urlbar, which this is not. Practically this isn't + // necessary since the client automatically resets the session on a timer + // whose period is much shorter than our fetch period, but there's no reason + // to keep it ticking in the meantime. + merino.resetSession(); + + if (merino != this.#merino || instance != this.#fetchInstance) { + this.logger.info("Fetch finished but is out of date, ignoring"); + } else { + let suggestion = suggestions?.[0]; + if (!suggestion) { + // No suggestion was received. The network may be offline or there may + // be some other problem. Set the suggestion to null: Better to show + // nothing than outdated weather information. When the network comes + // back online, one or more network notifications will be sent, + // triggering a new fetch. + this.logger.info("No suggestion received"); + this.#suggestion = null; + } else { + this.logger.info("Got suggestion"); + this.logger.debug(JSON.stringify({ suggestion })); + this.#suggestion = { ...suggestion, source: "merino" }; + } + } + + if (!this.#pendingFetchCount) { + this.#waitForFetchesDeferred?.resolve(); + this.#waitForFetchesDeferred = null; + } + } + + #restartFetchTimer(ms = this.#fetchIntervalMs) { + this.logger.debug( + "Restarting fetch timer: " + + JSON.stringify({ ms, fetchIntervalMs: this.#fetchIntervalMs }) + ); + + lazy.clearTimeout(this.#fetchTimer); + this.#fetchTimer = lazy.setTimeout(() => { + this.logger.debug("Fetch timer fired"); + this.#fetch(); + }, ms); + this._test_fetchTimerMs = ms; + } + + #onMaybeCameOnline() { + this.logger.debug("Maybe came online"); + + // If the suggestion is null, we were offline the last time we tried to + // fetch, at the start of the current fetch period. Otherwise the suggestion + // was fetched successfully at the start of the current fetch period and is + // therefore still fresh. + if (!this.suggestion) { + // Multiple notifications can occur at once when the network comes online, + // and we don't want to do separate fetches for each. Start the timer with + // a small timeout. If another notification happens in the meantime, we'll + // start it again. + this.#restartFetchTimer(this.#fetchDelayAfterComingOnlineMs); + } + } + + #onWake() { + // Calculate the elapsed time between the last fetch and now, and the + // remaining interval in the current fetch period. + let elapsedMs = Date.now() - this.#lastFetchTimeMs; + let remainingIntervalMs = this.#fetchIntervalMs - elapsedMs; + this.logger.debug( + "Wake: " + + JSON.stringify({ + elapsedMs, + remainingIntervalMs, + fetchIntervalMs: this.#fetchIntervalMs, + }) + ); + + // Regardless of the elapsed time, we need to restart the fetch timer + // because it didn't tick while the computer was asleep. If the elapsed time + // >= the fetch interval, the remaining interval will be negative and we + // need to fetch now, but do it after a brief delay in case other + // notifications occur soon when the network comes online. If the elapsed + // time < the fetch interval, the suggestion is still fresh so there's no + // need to fetch. Just restart the timer with the remaining interval. + if (remainingIntervalMs <= 0) { + remainingIntervalMs = this.#fetchDelayAfterComingOnlineMs; + } + this.#restartFetchTimer(remainingIntervalMs); + } + + #updateKeywords() { + this.logger.debug("Starting keywords update"); + + let nimbusKeywords = lazy.UrlbarPrefs.get("weatherKeywords"); + + // If the Rust backend is enabled and weather keywords aren't defined in + // Nimbus, Rust will manage the keywords. + if (lazy.UrlbarPrefs.get("quickSuggestRustEnabled") && !nimbusKeywords) { + this.logger.debug( + "Rust enabled, no keywords in Nimbus. " + + "Starting fetches and deferring to Rust." + ); + this.#keywords = null; + this.#startFetching(); + return; + } + + // If the JS backend is enabled but no keywords are defined, we can't + // possibly serve a weather suggestion. + if ( + !lazy.UrlbarPrefs.get("quickSuggestRustEnabled") && + !this.#config.keywords && + !nimbusKeywords + ) { + this.logger.debug( + "Rust disabled, no keywords in RS or Nimbus. Stopping fetches." + ); + this.#keywords = null; + this.#stopFetching(); + return; + } + + // At this point, keywords exist and this feature will manage them. + let fullKeywords = nimbusKeywords || this.#config.keywords; + let minLength = this.minKeywordLength; + this.logger.debug( + "Updating keywords: " + JSON.stringify({ fullKeywords, minLength }) + ); + + if (!minLength) { + this.logger.debug("Min length is undefined or zero, using full keywords"); + this.#keywords = new Set(fullKeywords); + } else { + // Create keywords that are prefixes of the full keywords starting at the + // specified minimum length. + this.#keywords = new Set(); + for (let full of fullKeywords) { + for (let i = minLength; i <= full.length; i++) { + this.#keywords.add(full.substring(0, i)); + } + } + } + + this.#startFetching(); + } + + onPrefChanged(pref) { + if (pref == "weather.minKeywordLength") { + this.#updateKeywords(); + } + } + + observe(subject, topic, data) { + this.logger.debug( + "Observed notification: " + JSON.stringify({ topic, data }) + ); + + switch (topic) { + case NOTIFICATIONS.CAPTIVE_PORTAL_LOGIN: + this.#onMaybeCameOnline(); + break; + case NOTIFICATIONS.LINK_STATUS_CHANGED: + // This notificaton means the user's connection status changed. See + // nsINetworkLinkService. + if (data != "down") { + this.#onMaybeCameOnline(); + } + break; + case NOTIFICATIONS.OFFLINE_STATUS_CHANGED: + // This notificaton means the user toggled the "Work Offline" pref. + // See nsIIOService. + if (data != "offline") { + this.#onMaybeCameOnline(); + } + break; + case NOTIFICATIONS.WAKE: + this.#onWake(); + break; + } + } + + get _test_fetchDelayAfterComingOnlineMs() { + return this.#fetchDelayAfterComingOnlineMs; + } + set _test_fetchDelayAfterComingOnlineMs(ms) { + this.#fetchDelayAfterComingOnlineMs = + ms < 0 ? FETCH_DELAY_AFTER_COMING_ONLINE_MS : ms; + } + + get _test_fetchIntervalMs() { + return this.#fetchIntervalMs; + } + set _test_fetchIntervalMs(ms) { + this.#fetchIntervalMs = ms < 0 ? FETCH_INTERVAL_MS : ms; + } + + get _test_fetchTimer() { + return this.#fetchTimer; + } + + get _test_lastFetchTimeMs() { + return this.#lastFetchTimeMs; + } + + get _test_merino() { + return this.#merino; + } + + get _test_pendingFetchCount() { + return this.#pendingFetchCount; + } + + async _test_fetch() { + await this.#fetch(); + } + + _test_setSuggestionToNull() { + this.#suggestion = null; + } + + _test_setTimeoutMs(ms) { + this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms; + } + + #fetchDelayAfterComingOnlineMs = FETCH_DELAY_AFTER_COMING_ONLINE_MS; + #fetchInstance = null; + #fetchIntervalMs = FETCH_INTERVAL_MS; + #fetchTimer = 0; + #keywords = null; + #lastFetchTimeMs = 0; + #merino = null; + #pendingFetchCount = 0; + #rsConfig = null; + #suggestion = null; + #timeoutMs = MERINO_TIMEOUT_MS; + #waitForFetchesDeferred = null; +} |